Merge branch 'master' into 886-YouMustTest

This commit is contained in:
Jordan Brown
2020-01-12 19:38:06 -05:00
committed by GitHub
59 changed files with 1587 additions and 492 deletions
+3
View File
@@ -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
+8
View File
@@ -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
+41 -15
View File
@@ -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
/// <summary>
/// The <see cref="ApiVersion"/> header key
/// </summary>
const string ApiVersionHeader = "Api";
/// <summary>
/// The <see cref="Username"/> header key
/// </summary>
const string UsernameHeader = "Username";
public const string ApiVersionHeader = "api";
/// <summary>
/// The <see cref="InstanceId"/> header key
/// </summary>
const string InstanceIdHeader = "Instance";
public const string InstanceIdHeader = "instance";
/// <summary>
/// The JWT authentication header scheme
/// </summary>
const string JwtAuthenticationScheme = "Bearer";
public const string JwtAuthenticationScheme = "bearer";
/// <summary>
/// The password authentication header scheme
/// The JWT authentication header scheme
/// </summary>
const string PasswordAuthenticationScheme = "Password";
public const string BasicAuthenticationScheme = "basic";
/// <summary>
/// The <see cref="Username"/> header key
/// </summary>
const string UsernameHeader = "username";
/// <summary>
/// The basic authentication header scheme
/// </summary>
const string PasswordAuthenticationScheme = "password";
/// <summary>
/// The current <see cref="System.Reflection.AssemblyName"/>
@@ -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());
@@ -0,0 +1,13 @@
namespace Tgstation.Server.Api.Models
{
/// <summary>
/// Base of <see cref="CompileJob"/>s.
/// </summary>
public class EntityId
{
/// <summary>
/// The ID of the entity.
/// </summary>
public long Id { get; set; }
}
}
@@ -6,13 +6,8 @@ namespace Tgstation.Server.Api.Models.Internal
/// <summary>
/// Represents a run of <see cref="DreamMaker"/>
/// </summary>
public class CompileJob
public class CompileJob : EntityId
{
/// <summary>
/// The ID of the job
/// </summary>
public long Id { get; set; }
/// <summary>
/// The .dme file used for compilation
/// </summary>
@@ -7,13 +7,8 @@ namespace Tgstation.Server.Api.Models.Internal
/// <summary>
/// Represents a long running job
/// </summary>
public class Job
public class Job : EntityId
{
/// <summary>
/// The <see cref="Job"/> ID
/// </summary>
public long Id { get; set; }
/// <summary>
/// English description of the <see cref="Job"/>
/// </summary>
@@ -60,7 +60,7 @@ namespace Tgstation.Server.Api.Rights
/// <returns>A <see cref="string"/> representing the claim role name</returns>
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));
}
+6 -1
View File
@@ -78,6 +78,11 @@ namespace Tgstation.Server.Api
/// </summary>
public const string Jobs = Root + nameof(Models.Job);
/// <summary>
/// The postfix for list operations
/// </summary>
public const string List = "List";
/// <summary>
/// Apply an <paramref name="id"/> postfix to a <paramref name="route"/>
/// </summary>
@@ -91,6 +96,6 @@ namespace Tgstation.Server.Api
/// </summary>
/// <param name="route">The route</param>
/// <returns>The <paramref name="route"/> with /List appended</returns>
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);
}
}
+5 -1
View File
@@ -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);
@@ -35,7 +35,7 @@ namespace Tgstation.Server.Client.Components
public Task<Byond> ActiveVersion(CancellationToken cancellationToken) => apiClient.Read<Byond>(Routes.Byond, instance.Id, cancellationToken);
/// <inheritdoc />
public Task<IReadOnlyList<Byond>> InstalledVersions(CancellationToken cancellationToken) => apiClient.Read<IReadOnlyList<Byond>>(Routes.List(Routes.Byond), instance.Id, cancellationToken);
public Task<IReadOnlyList<Byond>> InstalledVersions(CancellationToken cancellationToken) => apiClient.Read<IReadOnlyList<Byond>>(Routes.ListRoute(Routes.Byond), instance.Id, cancellationToken);
/// <inheritdoc />
public Task<Byond> SetActiveVersion(Byond byond, CancellationToken cancellationToken) => apiClient.Update<Byond, Byond>(Routes.Byond, byond ?? throw new ArgumentNullException(nameof(byond)), instance.Id, cancellationToken);
@@ -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);
/// <inheritdoc />
public Task<IReadOnlyList<ChatBot>> List(CancellationToken cancellationToken) => apiClient.Read<IReadOnlyList<ChatBot>>(Routes.List(Routes.Chat), instance.Id, cancellationToken);
public Task<IReadOnlyList<ChatBot>> List(CancellationToken cancellationToken) => apiClient.Read<IReadOnlyList<ChatBot>>(Routes.ListRoute(Routes.Chat), instance.Id, cancellationToken);
/// <inheritdoc />
public Task<ChatBot> Update(ChatBot settings, CancellationToken cancellationToken) => apiClient.Update<ChatBot, ChatBot>(Routes.Chat, settings ?? throw new ArgumentNullException(nameof(settings)), instance.Id, cancellationToken);
@@ -52,7 +52,7 @@ namespace Tgstation.Server.Client.Components
public Task<ConfigurationFile> CreateDirectory(ConfigurationFile directory, CancellationToken cancellationToken) => apiClient.Create<ConfigurationFile, ConfigurationFile>(Routes.Configuration, directory, instance.Id, cancellationToken);
/// <inheritdoc />
public Task<IReadOnlyList<ConfigurationFile>> List(string directory, CancellationToken cancellationToken) => apiClient.Read<IReadOnlyList<ConfigurationFile>>(Routes.List(Routes.Configuration) + SanitizeGetPath(directory), instance.Id, cancellationToken);
public Task<IReadOnlyList<ConfigurationFile>> List(string directory, CancellationToken cancellationToken) => apiClient.Read<IReadOnlyList<ConfigurationFile>>(Routes.ListRoute(Routes.Configuration) + SanitizeGetPath(directory), instance.Id, cancellationToken);
/// <inheritdoc />
public Task<ConfigurationFile> Read(ConfigurationFile file, CancellationToken cancellationToken)
@@ -35,10 +35,10 @@ namespace Tgstation.Server.Client.Components
public Task<Job> Compile(CancellationToken cancellationToken) => apiClient.Create<Job>(Routes.DreamMaker, instance.Id, cancellationToken);
/// <inheritdoc />
public Task<CompileJob> GetCompileJob(CompileJob compileJob, CancellationToken cancellationToken) => apiClient.Read<CompileJob>(Routes.SetID(Routes.DreamMaker, compileJob?.Id ?? throw new ArgumentNullException(nameof(compileJob))), instance.Id, cancellationToken);
public Task<CompileJob> GetCompileJob(EntityId compileJob, CancellationToken cancellationToken) => apiClient.Read<CompileJob>(Routes.SetID(Routes.DreamMaker, compileJob?.Id ?? throw new ArgumentNullException(nameof(compileJob))), instance.Id, cancellationToken);
/// <inheritdoc />
public Task<IReadOnlyList<CompileJob>> GetJobIds(CancellationToken cancellationToken) => apiClient.Read<IReadOnlyList<CompileJob>>(Routes.List(Routes.DreamMaker), instance.Id, cancellationToken);
public Task<IReadOnlyList<EntityId>> GetJobIds(CancellationToken cancellationToken) => apiClient.Read<IReadOnlyList<EntityId>>(Routes.ListRoute(Routes.DreamMaker), instance.Id, cancellationToken);
/// <inheritdoc />
public Task<DreamMaker> Read(CancellationToken cancellationToken) => apiClient.Read<DreamMaker>(Routes.DreamMaker, instance.Id, cancellationToken);
@@ -33,18 +33,18 @@ namespace Tgstation.Server.Client.Components
Task<Job> Compile(CancellationToken cancellationToken);
/// <summary>
/// Gets the <see cref="Api.Models.Internal.CompileJob.Id"/>s of all <see cref="CompileJob"/>s for the instance
/// Gets the <see cref="EntityId"/>s of all <see cref="CompileJob"/>s for the instance
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in a <see cref="IReadOnlyList{T}"/> of <see cref="CompileJob"/>s with only the <see cref="Api.Models.Internal.CompileJob.Id"/> field populated</returns>
Task<IReadOnlyList<CompileJob>> GetJobIds(CancellationToken cancellationToken);
/// <returns>A <see cref="Task{TResult}"/> resulting in a <see cref="IReadOnlyList{T}"/> of <see cref="CompileJob"/> <see cref="EntityId"/>s.</returns>
Task<IReadOnlyList<EntityId>> GetJobIds(CancellationToken cancellationToken);
/// <summary>
/// Get a <paramref name="compileJob"/>
/// </summary>
/// <param name="compileJob">The <see cref="CompileJob"/> to get</param>
/// <param name="compileJob">The <see cref="CompileJob"/> <see cref="EntityId"/> to get</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="CompileJob"/></returns>
Task<CompileJob> GetCompileJob(CompileJob compileJob, CancellationToken cancellationToken);
Task<CompileJob> GetCompileJob(EntityId compileJob, CancellationToken cancellationToken);
}
}
@@ -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
{
/// <summary>
/// List the <see cref="Api.Models.Internal.Job.Id"/>s in the <see cref="Instance"/>
/// List the <see cref="Job"/> <see cref="EntityId"/>s in the <see cref="Instance"/>
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in a <see cref="IReadOnlyList{T}"/> of the <see cref="Api.Models.Internal.Job.Id"/>s in the <see cref="Instance"/></returns>
Task<IReadOnlyList<Job>> List(CancellationToken cancellationToken);
/// <returns>A <see cref="Task{TResult}"/> resulting in a <see cref="IReadOnlyList{T}"/> of the <see cref="Job"/> <see cref="EntityId"/>s in the <see cref="Instance"/></returns>
Task<IReadOnlyList<EntityId>> List(CancellationToken cancellationToken);
/// <summary>
/// List the active <see cref="Job"/>s in the <see cref="Instance"/>
@@ -28,10 +27,10 @@ namespace Tgstation.Server.Client.Components
/// <summary>
/// Get a <paramref name="job"/>
/// </summary>
/// <param name="job">The <see cref="Job"/> to get</param>
/// <param name="job">The <see cref="Job"/>'s <see cref="EntityId"/> to get</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="Job"/></returns>
Task<Job> GetId(Job job, CancellationToken cancellationToken);
Task<Job> GetId(EntityId job, CancellationToken cancellationToken);
/// <summary>
/// Cancels a <paramref name="job"/>
@@ -44,7 +44,7 @@ namespace Tgstation.Server.Client.Components
public Task<InstanceUser> Update(InstanceUser instanceUser, CancellationToken cancellationToken) => apiClient.Update<InstanceUser, InstanceUser>(Routes.InstanceUser, instanceUser ?? throw new ArgumentNullException(nameof(instanceUser)), instance.Id, cancellationToken);
/// <inheritdoc />
public Task<IReadOnlyList<InstanceUser>> List(CancellationToken cancellationToken) => apiClient.Read<IReadOnlyList<InstanceUser>>(Routes.List(Routes.InstanceUser), instance.Id, cancellationToken);
public Task<IReadOnlyList<InstanceUser>> List(CancellationToken cancellationToken) => apiClient.Read<IReadOnlyList<InstanceUser>>(Routes.ListRoute(Routes.InstanceUser), instance.Id, cancellationToken);
/// <inheritdoc />
public Task<InstanceUser> GetId(InstanceUser instanceUser, CancellationToken cancellationToken) => apiClient.Read<InstanceUser>(Routes.SetID(Routes.InstanceUser, instanceUser?.UserId ?? throw new ArgumentNullException(nameof(instanceUser))), instance.Id, cancellationToken);
@@ -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);
/// <inheritdoc />
public Task<IReadOnlyList<Job>> List(CancellationToken cancellationToken) => apiClient.Read<IReadOnlyList<Job>>(Routes.List(Routes.Jobs), instance.Id, cancellationToken);
public Task<IReadOnlyList<EntityId>> List(CancellationToken cancellationToken) => apiClient.Read<IReadOnlyList<EntityId>>(Routes.ListRoute(Routes.Jobs), instance.Id, cancellationToken);
/// <inheritdoc />
public Task<IReadOnlyList<Job>> ListActive(CancellationToken cancellationToken) => apiClient.Read<IReadOnlyList<Job>>(Routes.Jobs, instance.Id, cancellationToken);
/// <inheritdoc />
public Task<Job> GetId(Job job, CancellationToken cancellationToken) => apiClient.Read<Job>(Routes.SetID(Routes.Jobs, job?.Id ?? throw new ArgumentNullException(nameof(job))), instance.Id, cancellationToken);
public Task<Job> GetId(EntityId job, CancellationToken cancellationToken) => apiClient.Read<Job>(Routes.SetID(Routes.Jobs, job?.Id ?? throw new ArgumentNullException(nameof(job))), instance.Id, cancellationToken);
}
}
@@ -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);
/// <inheritdoc />
public Task<IReadOnlyList<Instance>> List(CancellationToken cancellationToken) => apiClient.Read<IReadOnlyList<Instance>>(Routes.List(Routes.InstanceManager), cancellationToken);
public Task<IReadOnlyList<Instance>> List(CancellationToken cancellationToken) => apiClient.Read<IReadOnlyList<Instance>>(Routes.ListRoute(Routes.InstanceManager), cancellationToken);
/// <inheritdoc />
public Task<Instance> Update(Instance instance, CancellationToken cancellationToken) => apiClient.Update<Instance, Instance>(Routes.InstanceManager, instance ?? throw new ArgumentNullException(nameof(instance)), cancellationToken);
@@ -9,27 +9,18 @@ namespace Tgstation.Server.Client
/// </summary>
public sealed class ServerErrorException : ClientException
{
/// <summary>
/// The raw HTML of the error
/// </summary>
public string Html { get; }
/// <summary>
/// Construct an <see cref="ServerErrorException"/>
/// </summary>
public ServerErrorException() { }
/// <summary>
/// Construct an <see cref="ServerErrorException"/> with <paramref name="html"/>
/// Construct an <see cref="ServerErrorException"/> with a given <paramref name="errorMessage"/>
/// </summary>
/// <param name="html">The raw HTML response of the <see cref="ServerErrorException"/></param>
public ServerErrorException(string html) : base(new ErrorMessage
/// <param name="errorMessage">The <see cref="ErrorMessage"/> for the <see cref="ClientException"/></param>
/// <param name="statusCode">The <see cref="HttpStatusCode"/> for the <see cref="ClientException"/></param>
public ServerErrorException(ErrorMessage errorMessage, HttpStatusCode statusCode) : base(errorMessage, statusCode)
{
Message = "An internal server error occurred!",
SeverApiVersion = null
}, HttpStatusCode.InternalServerError)
{
Html = html;
}
/// <summary>
+1 -1
View File
@@ -31,7 +31,7 @@ namespace Tgstation.Server.Client
public Task<User> GetId(User user, CancellationToken cancellationToken) => apiClient.Read<User>(Routes.SetID(Routes.User, user?.Id ?? throw new ArgumentNullException(nameof(user))), cancellationToken);
/// <inheritdoc />
public Task<IReadOnlyList<User>> List(CancellationToken cancellationToken) => apiClient.Read<IReadOnlyList<User>>(Routes.List(Routes.User), cancellationToken);
public Task<IReadOnlyList<User>> List(CancellationToken cancellationToken) => apiClient.Read<IReadOnlyList<User>>(Routes.ListRoute(Routes.User), cancellationToken);
/// <inheritdoc />
public Task<User> Read(CancellationToken cancellationToken) => apiClient.Read<User>(Routes.User, cancellationToken);
@@ -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
{
/// <inheritdoc />
abstract class ByondInstallerBase : IByondInstaller
{
/// <inheritdoc />
public abstract string DreamDaemonName { get; }
/// <inheritdoc />
public abstract string DreamMakerName { get; }
/// <summary>
/// Gets the URL formatter string for downloading a byond version of {0:Major} {1:Minor}.
/// </summary>
protected abstract string ByondRevisionsURLTemplate { get; }
/// <summary>
/// Gets the <see cref="IIOManager"/> for the <see cref="ByondInstallerBase"/>.
/// </summary>
protected IIOManager IOManager { get; }
/// <summary>
/// Gets the <see cref="ILogger"/> for the <see cref="ByondInstallerBase"/>.
/// </summary>
protected ILogger<ByondInstallerBase> Logger { get; }
/// <summary>
/// Initializes a new instance of the <see cref="ByondInstallerBase"/> <see langword="class"/>.
/// </summary>
/// <param name="ioManager">The value of <see cref="IOManager"/>.</param>
/// <param name="logger">The value of <see cref="Logger"/>.</param>
protected ByondInstallerBase(IIOManager ioManager, ILogger<ByondInstallerBase> logger)
{
IOManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager));
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <inheritdoc />
public abstract Task CleanCache(CancellationToken cancellationToken);
/// <inheritdoc />
public abstract Task InstallByond(string path, Version version, CancellationToken cancellationToken);
/// <inheritdoc />
public Task<byte[]> 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);
}
}
}
@@ -11,13 +11,8 @@ namespace Tgstation.Server.Host.Components.Byond
/// <summary>
/// <see cref="IByondInstaller"/> for Posix systems
/// </summary>
sealed class PosixByondInstaller : IByondInstaller
sealed class PosixByondInstaller : ByondInstallerBase
{
/// <summary>
/// The URL format string for getting BYOND linux version {0}.{1} zipfile
/// </summary>
const string ByondRevisionsURLTemplate = "https://secure.byond.com/download/build/{0}/{0}.{1}_byond_linux.zip";
/// <summary>
/// Path to the BYOND cache
/// </summary>
@@ -28,45 +23,37 @@ namespace Tgstation.Server.Host.Components.Byond
const string ShellScriptExtension = ".sh";
/// <inheritdoc />
public string DreamDaemonName => DreamDaemonExecutableName + ShellScriptExtension;
public override string DreamDaemonName => DreamDaemonExecutableName + ShellScriptExtension;
/// <inheritdoc />
public string DreamMakerName => DreamMakerExecutableName + ShellScriptExtension;
public override string DreamMakerName => DreamMakerExecutableName + ShellScriptExtension;
/// <summary>
/// The <see cref="IIOManager"/> for the <see cref="PosixByondInstaller"/>
/// </summary>
readonly IIOManager ioManager;
/// <inheritdoc />
protected override string ByondRevisionsURLTemplate => "https://secure.byond.com/download/build/{0}/{0}.{1}_byond_linux.zip";
/// <summary>
/// The <see cref="IPostWriteHandler"/> for the <see cref="PosixByondInstaller"/>
/// </summary>
readonly IPostWriteHandler postWriteHandler;
/// <summary>
/// The <see cref="ILogger"/> for the <see cref="PosixByondInstaller"/>
/// </summary>
readonly ILogger<PosixByondInstaller> logger;
/// <summary>
/// Construct a <see cref="WindowsByondInstaller"/>
/// </summary>
/// <param name="ioManager">The value of <see cref="ioManager"/></param>
/// <param name="postWriteHandler">The value of <see cref="postWriteHandler"/></param>
/// <param name="logger">The value of <see cref="logger"/></param>
public PosixByondInstaller(IIOManager ioManager, IPostWriteHandler postWriteHandler, ILogger<PosixByondInstaller> logger)
/// <param name="ioManager">The <see cref="IIOManager"/> for the <see cref="ByondInstallerBase"/>.</param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="ByondInstallerBase"/>.</param>
public PosixByondInstaller(IPostWriteHandler postWriteHandler, IIOManager ioManager, ILogger<PosixByondInstaller> 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));
}
/// <inheritdoc />
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);
}
}
/// <inheritdoc />
public async Task<byte[]> 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);
}
/// <inheritdoc />
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;
}
@@ -12,13 +12,8 @@ namespace Tgstation.Server.Host.Components.Byond
/// <summary>
/// <see cref="IByondInstaller"/> for windows systems
/// </summary>
sealed class WindowsByondInstaller : IByondInstaller, IDisposable
sealed class WindowsByondInstaller : ByondInstallerBase, IDisposable
{
/// <summary>
/// The URL format string for getting BYOND windows version {0}.{1} zipfile
/// </summary>
const string ByondRevisionsURLTemplate = "https://secure.byond.com/download/build/{0}/{0}.{1}_byond.zip";
/// <summary>
/// Directory to byond installation configuration
/// </summary>
@@ -40,26 +35,19 @@ namespace Tgstation.Server.Host.Components.Byond
const string ByondDXDir = "byond/directx";
/// <inheritdoc />
public string DreamDaemonName => "dreamdaemon.exe";
public override string DreamDaemonName => "dreamdaemon.exe";
/// <inheritdoc />
public string DreamMakerName => "dm.exe";
public override string DreamMakerName => "dm.exe";
/// <summary>
/// The <see cref="IIOManager"/> for the <see cref="WindowsByondInstaller"/>
/// </summary>
readonly IIOManager ioManager;
/// <inheritdoc />
protected override string ByondRevisionsURLTemplate => "https://secure.byond.com/download/build/{0}/{0}.{1}_byond.zip";
/// <summary>
/// The <see cref="IProcessExecutor"/> for the <see cref="WindowsByondInstaller"/>
/// </summary>
readonly IProcessExecutor processExecutor;
/// <summary>
/// The <see cref="ILogger"/> for the <see cref="WindowsByondInstaller"/>
/// </summary>
readonly ILogger<WindowsByondInstaller> logger;
/// <summary>
/// The <see cref="SemaphoreSlim"/> for the <see cref="WindowsByondInstaller"/>
/// </summary>
@@ -73,14 +61,13 @@ namespace Tgstation.Server.Host.Components.Byond
/// <summary>
/// Construct a <see cref="WindowsByondInstaller"/>
/// </summary>
/// <param name="ioManager">The value of <see cref="ioManager"/></param>
/// <param name="processExecutor">The value of <see cref="processExecutor"/></param>
/// <param name="logger">The value of <see cref="logger"/></param>
public WindowsByondInstaller(IIOManager ioManager, IProcessExecutor processExecutor, ILogger<WindowsByondInstaller> logger)
/// <param name="ioManager">The <see cref="IIOManager"/> for the <see cref="ByondInstallerBase"/>.</param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="ByondInstallerBase"/>.</param>
public WindowsByondInstaller(IProcessExecutor processExecutor, IIOManager ioManager, ILogger<WindowsByondInstaller> 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();
/// <inheritdoc />
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);
}
}
/// <inheritdoc />
public Task<byte[]> DownloadVersion(Version version, CancellationToken cancellationToken)
{
var url = String.Format(CultureInfo.InvariantCulture, ByondRevisionsURLTemplate, version.Major, version.Minor);
return ioManager.DownloadFile(new Uri(url), cancellationToken);
}
/// <inheritdoc />
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)
{
@@ -22,10 +22,10 @@ using Tgstation.Server.Host.Security;
namespace Tgstation.Server.Host.Controllers
{
/// <summary>
/// <see cref="ModelController{TModel}"/> for <see cref="Administration"/>
/// <see cref="ApiController"/> for <see cref="Administration"/> purposes
/// </summary>
[Route(Routes.Administration)]
public sealed class AdministrationController : ModelController<Administration>
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
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="ApiController"/></param>
/// <param name="updatesConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing value of <see cref="updatesConfiguration"/></param>
/// <param name="generalConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing value of <see cref="generalConfiguration"/></param>
public AdministrationController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IGitHubClientFactory gitHubClientFactory, IServerControl serverUpdater, IApplication application, IIOManager ioManager, IPlatformIdentifier platformIdentifier, ILogger<AdministrationController> logger, IOptions<UpdatesConfiguration> updatesConfigurationOptions, IOptions<GeneralConfiguration> generalConfigurationOptions) : base(databaseContext, authenticationContextFactory, logger, false)
public AdministrationController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IGitHubClientFactory gitHubClientFactory, IServerControl serverUpdater, IApplication application, IIOManager ioManager, IPlatformIdentifier platformIdentifier, ILogger<AdministrationController> logger, IOptions<UpdatesConfiguration> updatesConfigurationOptions, IOptions<GeneralConfiguration> 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);
/// <inheritdoc />
/// <summary>
/// Get <see cref="Administration"/> server information.
/// </summary>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> for the operation.</returns>
/// <response code="200">Retrieved <see cref="Administration"/> data successfully.</response>
/// <response code="424">The GitHub API rate limit was hit. See response header Retry-After.</response>
/// <response code="429">A GitHub API error occurred. See error message for details.</response>
[HttpGet]
[TgsAuthorize]
public override async Task<IActionResult> Read(CancellationToken cancellationToken)
[ProducesResponseType(typeof(Administration), 200)]
[ProducesResponseType(424)]
[ProducesResponseType(typeof(ErrorMessage), 429)]
public async Task<IActionResult> 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
});
}
}
/// <inheritdoc />
/// <summary>
/// Attempt to perform a server upgrade.
/// </summary>
/// <param name="model">The model containing the <see cref="Administration.NewVersion"/> to update to.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> for the operation.</returns>
/// <response code="422">Upgrade operations are unavailable due to the launch configuration of TGS.</response>
[HttpPost]
[TgsAuthorize(AdministrationRights.ChangeVersion)]
public override async Task<IActionResult> Update([FromBody] Administration model, CancellationToken cancellationToken)
[ProducesResponseType(typeof(ErrorMessage), 422)]
public async Task<IActionResult> 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
/// </summary>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the request</returns>
[HttpDelete]
/// <response code="200">Restart begun successfully.</response>
/// <response code="422">Restart operations are unavailable due to the launch configuration of TGS.</response>
[HttpDelete("{id}")]
[TgsAuthorize(AdministrationRights.RestartHost)]
[ProducesResponseType(200)]
[ProducesResponseType(typeof(ErrorMessage), 422)]
public async Task<IActionResult> Delete()
{
try
@@ -18,6 +18,7 @@ namespace Tgstation.Server.Host.Controllers
/// A <see cref="Controller"/> for API functions
/// </summary>
[Produces(ApiHeaders.ApplicationJson)]
[ApiController]
public abstract class ApiController : Controller
{
/// <summary>
@@ -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)
@@ -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 <see cref="Api.Models.Byond.Version"/>s
/// </summary>
[Route(Routes.Byond)]
public sealed class ByondController : ModelController<Api.Models.Byond>
public sealed class ByondController : ApiController
{
/// <summary>
/// The <see cref="IInstanceManager"/> for the <see cref="ByondController"/>
@@ -39,31 +39,53 @@ namespace Tgstation.Server.Host.Controllers
/// <param name="instanceManager">The value of <see cref="instanceManager"/></param>
/// <param name="jobManager">The value of <see cref="jobManager"/></param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="ApiController"/></param>
public ByondController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IInstanceManager instanceManager, IJobManager jobManager, ILogger<ByondController> logger) : base(databaseContext, authenticationContextFactory, logger, true)
public ByondController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IInstanceManager instanceManager, IJobManager jobManager, ILogger<ByondController> logger) : base(databaseContext, authenticationContextFactory, logger, true, true)
{
this.instanceManager = instanceManager ?? throw new ArgumentNullException(nameof(instanceManager));
this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager));
}
/// <inheritdoc />
/// <summary>
/// Gets the active <see cref="Api.Models.Byond"/> version.
/// </summary>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> for the operation.</returns>
/// <response code="200">Retrieved version information successfully.</response>
[HttpGet]
[TgsAuthorize(ByondRights.ReadActive)]
public override Task<IActionResult> Read(CancellationToken cancellationToken) => Task.FromResult<IActionResult>(
[ProducesResponseType(typeof(Api.Models.Byond), 200)]
public Task<IActionResult> Read() => Task.FromResult<IActionResult>(
Json(new Api.Models.Byond
{
Version = instanceManager.GetInstance(Instance).ByondManager.ActiveVersion
}));
/// <inheritdoc />
/// <summary>
/// Lists installed <see cref="Api.Models.Byond"/> versions.
/// </summary>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> for the operation.</returns>
/// <response code="200">Retrieved version information successfully.</response>
[HttpGet(Routes.List)]
[TgsAuthorize(ByondRights.ListInstalled)]
public override Task<IActionResult> List(CancellationToken cancellationToken) => Task.FromResult<IActionResult>(
[ProducesResponseType(typeof(IEnumerable<Api.Models.Byond>), 200)]
public Task<IActionResult> List() => Task.FromResult<IActionResult>(
Json(instanceManager.GetInstance(Instance).ByondManager.InstalledVersions.Select(x => new Api.Models.Byond
{
Version = x
})));
/// <inheritdoc />
/// <summary>
/// Changes the active BYOND version to the one specified in a given <paramref name="model"/>.
/// </summary>
/// <param name="model">The <see cref="Api.Models.Byond.Version"/> to switch to.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> for the operation.</returns>
/// <response code="200">Switched active version successfully.</response>
/// <response code="202">Created <see cref="Api.Models.Job"/> to install and switch active version successfully.</response>
[HttpPost]
[TgsAuthorize(ByondRights.ChangeVersion)]
public override async Task<IActionResult> Update([FromBody] Api.Models.Byond model, CancellationToken cancellationToken)
[ProducesResponseType(typeof(Api.Models.Byond), 200)]
[ProducesResponseType(typeof(Api.Models.Byond), 202)]
public async Task<IActionResult> 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,
@@ -20,10 +20,10 @@ using Z.EntityFramework.Plus;
namespace Tgstation.Server.Host.Controllers
{
/// <summary>
/// <see cref="ModelController{TModel}"/> for managing <see cref="Api.Models.ChatBot"/>s
/// <see cref="ApiController"/> for managing <see cref="Api.Models.ChatBot"/>s
/// </summary>
[Route(Routes.Chat)]
public sealed class ChatController : ModelController<Api.Models.ChatBot>
public sealed class ChatController : ApiController
{
/// <summary>
/// The <see cref="IInstanceManager"/> for the <see cref="ChatController"/>
@@ -37,7 +37,7 @@ namespace Tgstation.Server.Host.Controllers
/// <param name="authenticationContextFactory">The <see cref="IAuthenticationContextFactory"/> for the <see cref="ApiController"/></param>
/// <param name="instanceManager">The value of <see cref="instanceManager"/></param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="ApiController"/></param>
public ChatController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IInstanceManager instanceManager, ILogger<ChatController> logger) : base(databaseContext, authenticationContextFactory, logger, true)
public ChatController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IInstanceManager instanceManager, ILogger<ChatController> 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
};
/// <inheritdoc />
/// <summary>
/// Create a new chat bot <paramref name="model"/>.
/// </summary>
/// <param name="model">The <see cref="Api.Models.ChatBot"/> to create.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> for the operation.</returns>
/// <response code="201">Created chat bot successfully.</response>
[HttpPut]
[TgsAuthorize(ChatBotRights.Create)]
public override async Task<IActionResult> Create([FromBody] Api.Models.ChatBot model, CancellationToken cancellationToken)
[ProducesResponseType(typeof(Api.Models.ChatBot), 201)]
public async Task<IActionResult> 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());
}
/// <inheritdoc />
/// <summary>
/// Delete a <see cref="Api.Models.ChatBot"/>.
/// </summary>
/// <param name="id">The <see cref="Api.Models.Internal.ChatBot.Id"/> to delete.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> for the operation.</returns>
/// <response code="200">Chat bot deleted or does not exist.</response>
[HttpDelete("{id}")]
[TgsAuthorize(ChatBotRights.Delete)]
public override async Task<IActionResult> Delete(long id, CancellationToken cancellationToken)
[ProducesResponseType(200)]
public async Task<IActionResult> 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();
}
/// <inheritdoc />
/// <summary>
/// List <see cref="Api.Models.ChatBot"/>s.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> for the operation.</returns>
/// <response code="200">Listed chat bots successfully.</response>
[HttpGet(Routes.List)]
[TgsAuthorize(ChatBotRights.Read)]
public override async Task<IActionResult> List(CancellationToken cancellationToken)
[ProducesResponseType(typeof(IEnumerable<Api.Models.ChatBot>), 200)]
public async Task<IActionResult> 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()));
}
/// <inheritdoc />
/// <summary>
/// Get a specific <see cref="Api.Models.ChatBot"/>.
/// </summary>
/// <param name="id">The <see cref="Api.Models.Internal.ChatBot.Id"/> to retrieve.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> for the operation.</returns>
/// <response code="200">Retrieved <see cref="Api.Models.ChatBot"/> successfully.</response>
/// <response code="410">Chat bot does not exist.</response>
[HttpGet("{id}")]
[TgsAuthorize(ChatBotRights.Read)]
public override async Task<IActionResult> GetId(long id, CancellationToken cancellationToken)
[ProducesResponseType(typeof(Api.Models.ChatBot), 200)]
[ProducesResponseType(410)]
public async Task<IActionResult> 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());
}
/// <inheritdoc />
#pragma warning disable CA1506 // TODO: Decomplexify
/// <summary>
/// Updates a chat bot <paramref name="model"/>.
/// </summary>
/// <param name="model">The <see cref="Api.Models.ChatBot"/> update to apply.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> for the operation.</returns>
/// <response code="200">Update applied successfully. <see cref="Api.Models.ChatBot"/> may or may not be returned based on user permissions.</response>
[HttpPost]
[TgsAuthorize(ChatBotRights.WriteChannels | ChatBotRights.WriteConnectionString | ChatBotRights.WriteEnabled | ChatBotRights.WriteName | ChatBotRights.WriteProvider)]
public override async Task<IActionResult> 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<IActionResult> Update([FromBody] Api.Models.ChatBot model, CancellationToken cancellationToken)
{
if (model == null)
throw new ArgumentNullException(nameof(model));
@@ -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
{
/// <summary>
/// The <see cref="ModelController{TModel}"/> for <see cref="ConfigurationFile"/>s
/// The <see cref="ApiController"/> for <see cref="ConfigurationFile"/>s
/// </summary>
[Route(Routes.Configuration)]
public sealed class ConfigurationController : ModelController<ConfigurationFile>
public sealed class ConfigurationController : ApiController
{
/// <summary>
/// The <see cref="IInstanceManager"/> for the <see cref="ConfigurationController"/>
@@ -39,7 +40,7 @@ namespace Tgstation.Server.Host.Controllers
/// <param name="instanceManager">The value of <see cref="instanceManager"/></param>
/// <param name="ioManager">The value of <see cref="ioManager"/></param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="ApiController"/></param>
public ConfigurationController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IInstanceManager instanceManager, IIOManager ioManager, ILogger<ConfigurationController> logger) : base(databaseContext, authenticationContextFactory, logger, true)
public ConfigurationController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IInstanceManager instanceManager, IIOManager ioManager, ILogger<ConfigurationController> 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;
}
/// <inheritdoc />
/// <summary>
/// Write to a configuration file.
/// </summary>
/// <param name="model">The <see cref="ConfigurationFile"/> representing the file.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> for the operation.</returns>
/// <response code="200">File updated successfully.</response>
/// <response code="200">File created successfully.</response>
/// <response code="501">POSIX system impersonation requested but not implemented.</response>
[HttpPost]
[TgsAuthorize(ConfigurationRights.Write)]
public override async Task<IActionResult> Update([FromBody] ConfigurationFile model, CancellationToken cancellationToken)
[ProducesResponseType(typeof(ConfigurationFile), 200)]
[ProducesResponseType(typeof(ConfigurationFile), 201)]
[ProducesResponseType(501)]
public async Task<IActionResult> Update([FromBody] ConfigurationFile model, CancellationToken cancellationToken)
{
if (model == null)
throw new ArgumentNullException(nameof(model));
@@ -106,8 +119,13 @@ namespace Tgstation.Server.Host.Controllers
/// <param name="filePath">The path of the file to get</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> for the operation</returns>
/// <response code="410">File not found on disk.</response>
/// <response code="501">POSIX system impersonation requested but not implemented.</response>
[HttpGet(Routes.File + "/{*filePath}")]
[TgsAuthorize(ConfigurationRights.Read)]
[ProducesResponseType(typeof(ConfigurationFile), 200)]
[ProducesResponseType(410)]
[ProducesResponseType(501)]
public async Task<IActionResult> File(string filePath, CancellationToken cancellationToken)
{
if (ForbidDueToModeConflicts(filePath, out var systemIdentity))
@@ -141,8 +159,13 @@ namespace Tgstation.Server.Host.Controllers
/// <param name="directoryPath">The path of the directory to get</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> for the operation</returns>
[HttpGet("List/{*directoryPath}")]
/// <response code="410">Directory not found on disk.</response>
/// <response code="501">POSIX system impersonation requested but not implemented.</response>
[HttpGet(Routes.List + "/{*directoryPath}")]
[TgsAuthorize(ConfigurationRights.List)]
[ProducesResponseType(typeof(IReadOnlyList<ConfigurationFile>), 200)]
[ProducesResponseType(410)]
[ProducesResponseType(501)]
public async Task<IActionResult> Directory(string directoryPath, CancellationToken cancellationToken)
{
if (ForbidDueToModeConflicts(directoryPath, out var systemIdentity))
@@ -166,13 +189,35 @@ namespace Tgstation.Server.Host.Controllers
}
}
/// <inheritdoc />
/// <summary>
/// Get the contents of the root configuration directory.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> for the operation.</returns>
/// <response code="410">Directory not found on disk.</response>
/// <response code="501">POSIX system impersonation requested but not implemented.</response>
[HttpGet(Routes.List)]
[TgsAuthorize(ConfigurationRights.List)]
public override Task<IActionResult> List(CancellationToken cancellationToken) => Directory(null, cancellationToken);
[ProducesResponseType(typeof(IReadOnlyList<ConfigurationFile>), 200)]
[ProducesResponseType(410)]
[ProducesResponseType(501)]
public Task<IActionResult> List(CancellationToken cancellationToken) => Directory(null, cancellationToken);
/// <inheritdoc />
/// <summary>
/// Create a configuration directory.
/// </summary>
/// <param name="model">The <see cref="ConfigurationFile"/> representing the directory.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> for the operation.</returns>
/// <response code="200">Directory already exists.</response>
/// <response code="201">Directory created successfully.</response>
/// <response code="501">POSIX system impersonation requested but not implemented.</response>
[HttpPut]
[TgsAuthorize(ConfigurationRights.Write)]
public override async Task<IActionResult> Create([FromBody] ConfigurationFile model, CancellationToken cancellationToken)
[ProducesResponseType(typeof(ConfigurationFile), 200)]
[ProducesResponseType(typeof(ConfigurationFile), 201)]
[ProducesResponseType(501)]
public async Task<IActionResult> 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
/// <param name="directory">A <see cref="ConfigurationFile"/> representing the path to the directory to delete</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the operation</returns>
/// <response code="200">Empty directory deleted successfully.</response>
/// <response code="501">POSIX system impersonation requested but not implemented.</response>
[HttpDelete]
[TgsAuthorize(ConfigurationRights.Delete)]
[ProducesResponseType(200)]
[ProducesResponseType(501)]
public async Task<IActionResult> Delete([FromBody] ConfigurationFile directory, CancellationToken cancellationToken)
{
if (directory == null)
@@ -20,10 +20,10 @@ using Tgstation.Server.Host.Security;
namespace Tgstation.Server.Host.Controllers
{
/// <summary>
/// <see cref="ModelController{TModel}"/> for managing the <see cref="DreamDaemon"/>
/// <see cref="ApiController"/> for managing the <see cref="DreamDaemon"/>
/// </summary>
[Route(Routes.DreamDaemon)]
public sealed class DreamDaemonController : ModelController<DreamDaemon>
public sealed class DreamDaemonController : ApiController
{
/// <summary>
/// The <see cref="IJobManager"/> for the <see cref="DreamMakerController"/>
@@ -43,15 +43,24 @@ namespace Tgstation.Server.Host.Controllers
/// <param name="jobManager">The value of <see cref="jobManager"/></param>
/// <param name="instanceManager">The value of <see cref="instanceManager"/></param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="ApiController"/></param>
public DreamDaemonController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IJobManager jobManager, IInstanceManager instanceManager, ILogger<DreamDaemonController> logger) : base(databaseContext, authenticationContextFactory, logger, true)
public DreamDaemonController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IJobManager jobManager, IInstanceManager instanceManager, ILogger<DreamDaemonController> logger) : base(databaseContext, authenticationContextFactory, logger, true, true)
{
this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager));
this.instanceManager = instanceManager ?? throw new ArgumentNullException(nameof(instanceManager));
}
/// <inheritdoc />
/// <summary>
/// Launches the watchdog.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the operation.</returns>
/// <response code="202"><see cref="Api.Models.Job"/> to launch the watchdog started successfully.</response>
/// <response code="410">Watchdog already running.</response>
[HttpPut]
[TgsAuthorize(DreamDaemonRights.Start)]
public override async Task<IActionResult> Create([FromBody] DreamDaemon model, CancellationToken cancellationToken)
[ProducesResponseType(typeof(Api.Models.Job), 202)]
[ProducesResponseType(410)]
public async Task<IActionResult> 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());
}
/// <inheritdoc />
/// <summary>
/// Get the watchdog status.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the operation.</returns>
/// <response code="202">Read <see cref="DreamDaemon"/> information successfully.</response>
[HttpGet]
[TgsAuthorize(DreamDaemonRights.ReadMetadata | DreamDaemonRights.ReadRevision)]
public override Task<IActionResult> Read(CancellationToken cancellationToken) => ReadImpl(null, cancellationToken);
[ProducesResponseType(typeof(DreamDaemon), 200)]
[ProducesResponseType(410)]
public Task<IActionResult> Read(CancellationToken cancellationToken) => ReadImpl(null, cancellationToken);
/// <summary>
/// Implementation of <see cref="Read(CancellationToken)"/>
@@ -128,12 +145,14 @@ namespace Tgstation.Server.Host.Controllers
}
/// <summary>
/// Stops DreamDaemon if it's running
/// Stops the Watchdog if it's running.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the operation</returns>
[HttpDelete]
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the operation.</returns>
/// <response code="200">Watchdog terminated.</response>
[HttpDelete("{id}")]
[TgsAuthorize(DreamDaemonRights.Shutdown)]
[ProducesResponseType(200)]
public async Task<IActionResult> Delete(CancellationToken cancellationToken)
{
var instance = instanceManager.GetInstance(Instance);
@@ -141,10 +160,20 @@ namespace Tgstation.Server.Host.Controllers
return Ok();
}
/// <inheritdoc />
#pragma warning disable CA1506 // TODO: Decomplexify
/// <summary>
/// Update watchdog settings to be applied at next server reboot.
/// </summary>
/// <param name="model">The updated <see cref="DreamDaemon"/> settings.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the operation.</returns>
/// <response code="200">Settings applied successfully.</response>
/// <response code="410">Instance no longer available.</response>
[HttpPost]
[TgsAuthorize(DreamDaemonRights.SetAutoStart | DreamDaemonRights.SetPorts | DreamDaemonRights.SetSecurity | DreamDaemonRights.SetWebClient | DreamDaemonRights.SoftRestart | DreamDaemonRights.SoftShutdown | DreamDaemonRights.Start | DreamDaemonRights.SetStartupTimeout)]
public override async Task<IActionResult> Update([FromBody] DreamDaemon model, CancellationToken cancellationToken)
[ProducesResponseType(typeof(DreamDaemon), 200)]
[ProducesResponseType(410)]
#pragma warning disable CA1506 // TODO: Decomplexify
public async Task<IActionResult> 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
/// <summary>
/// Handle a HTTP PATCH to the <see cref="DreamDaemonController"/>
/// Creates a <see cref="Api.Models.Job"/> to restart the Watchdog. It will start if it wasn't already running.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the request</returns>
/// <response code="202">Restart <see cref="Api.Models.Job"/> started successfully.</response>
[HttpPatch]
[TgsAuthorize(DreamDaemonRights.Restart)]
[ProducesResponseType(typeof(Api.Models.Job), 202)]
public async Task<IActionResult> Restart(CancellationToken cancellationToken)
{
var job = new Models.Job
@@ -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
{
/// <summary>
/// Controller for managing the compiler
/// <see cref="ApiController"/> for managing the deployment system.
/// </summary>
[Route(Routes.DreamMaker)]
#pragma warning disable CA1506 // TODO: Decomplexify
public sealed class DreamMakerController : ModelController<DreamMaker>
public sealed class DreamMakerController : ApiController
{
/// <summary>
/// The <see cref="IJobManager"/> for the <see cref="DreamMakerController"/>
@@ -41,24 +42,41 @@ namespace Tgstation.Server.Host.Controllers
/// <param name="jobManager">The value of <see cref="jobManager"/></param>
/// <param name="instanceManager">The value of <see cref="instanceManager"/></param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="ApiController"/></param>
public DreamMakerController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IJobManager jobManager, IInstanceManager instanceManager, ILogger<DreamMakerController> logger) : base(databaseContext, authenticationContextFactory, logger, true)
public DreamMakerController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IJobManager jobManager, IInstanceManager instanceManager, ILogger<DreamMakerController> logger) : base(databaseContext, authenticationContextFactory, logger, true, true)
{
this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager));
this.instanceManager = instanceManager ?? throw new ArgumentNullException(nameof(instanceManager));
}
/// <inheritdoc />
/// <summary>
/// Read current <see cref="DreamMaker"/> status.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the request.</returns>
/// <response code="200">Read <see cref="DreamMaker"/> status successfully.</response>
[HttpGet]
[TgsAuthorize(DreamMakerRights.Read)]
public override async Task<IActionResult> Read(CancellationToken cancellationToken)
[ProducesResponseType(typeof(DreamMaker), 200)]
public async Task<IActionResult> 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());
}
/// <inheritdoc />
/// <summary>
/// Get a <see cref="Api.Models.CompileJob"/> specified by a given <paramref name="id"/>.
/// </summary>
/// <param name="id">The <see cref="EntityId.Id"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the request.</returns>
/// <response code="200"><see cref="Api.Models.CompileJob"/> retrieved successfully.</response>
/// <response code="404">Specified compile job does not exist in this instance.</response>
[HttpGet("{id}")]
[TgsAuthorize(DreamMakerRights.CompileJobs)]
public override async Task<IActionResult> GetId(long id, CancellationToken cancellationToken)
[ProducesResponseType(typeof(Api.Models.CompileJob), 200)]
[ProducesResponseType(404)]
public async Task<IActionResult> 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());
}
/// <inheritdoc />
/// <summary>
/// List all <see cref="Api.Models.CompileJob"/> <see cref="EntityId"/>s for the instance.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the request.</returns>
/// <response code="200">Retrieved <see cref="EntityId"/>s successfully.</response>
[HttpGet(Routes.List)]
[TgsAuthorize(DreamMakerRights.CompileJobs)]
public override async Task<IActionResult> List(CancellationToken cancellationToken)
[ProducesResponseType(typeof(List<EntityId>), 200)]
public async Task<IActionResult> 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);
}
/// <inheritdoc />
/// <summary>
/// Begin deploying repository code.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the request.</returns>
/// <response code="202">Created deployment <see cref="Api.Models.Job"/> successfully.</response>
[HttpPut]
[TgsAuthorize(DreamMakerRights.Compile)]
public override async Task<IActionResult> Create([FromBody] DreamMaker model, CancellationToken cancellationToken)
[ProducesResponseType(typeof(Api.Models.Job), 202)]
public async Task<IActionResult> Create(CancellationToken cancellationToken)
{
var job = new Models.Job
{
@@ -98,10 +130,24 @@ namespace Tgstation.Server.Host.Controllers
return Accepted(job.ToApi());
}
/// <inheritdoc />
/// <summary>
/// Update deployment settings.
/// </summary>
/// <param name="model">The updated <see cref="DreamMaker"/> settings.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the request.</returns>
/// <response code="200">Changes applied successfully. The updated <see cref="DreamMaker"/> settings will be returned based on user permissions.</response>
/// <response code="410">Instance no longer available.</response>
[HttpPost]
[TgsAuthorize(DreamMakerRights.SetDme | DreamMakerRights.SetApiValidationPort | DreamMakerRights.SetApiValidationPort)]
public override async Task<IActionResult> Update([FromBody] DreamMaker model, CancellationToken cancellationToken)
[ProducesResponseType(typeof(DreamMaker), 200)]
[ProducesResponseType(200)]
[ProducesResponseType(410)]
public async Task<IActionResult> 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!" });
@@ -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
/// <summary>
/// Main page of the <see cref="Application"/>
/// </summary>
/// <returns>The <see cref="Api.Models.ServerInformation"/> of the <see cref="Application"/> if a properly authenticated API request, the web control panel if on a browser and enabled, <see cref="UnauthorizedResult"/> otherwise</returns>
/// <returns>
/// The <see cref="Api.Models.ServerInformation"/> of the <see cref="Application"/> if a properly authenticated API request, the web control panel if on a browser and enabled, <see cref="UnauthorizedResult"/> otherwise.
/// </returns>
/// <response code="200"><see cref="Api.Models.ServerInformation"/> retrieved successfully.</response>
[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
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the operation</returns>
/// <response code="200">User logged in and <see cref="Api.Models.Token"/> generated successfully.</response>
/// <response code="401">User authentication failed.</response>
/// <response code="403">User authenticated but is disabled by an administrator.</response>
[HttpPost]
[ProducesResponseType(typeof(Api.Models.Token), 200)]
[ProducesResponseType(401)]
[ProducesResponseType(403)]
#pragma warning disable CA1506 // TODO: Decomplexify
public async Task<IActionResult> 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)
@@ -23,11 +23,11 @@ using Tgstation.Server.Host.Security;
namespace Tgstation.Server.Host.Controllers
{
/// <summary>
/// Controller for managing <see cref="Components.Instance"/>s
/// <see cref="ApiController"/> for managing <see cref="Components.Instance"/>s
/// </summary>
[Route(Routes.InstanceManager)]
#pragma warning disable CA1506 // TODO: Decomplexify
public sealed class InstanceController : ModelController<Api.Models.Instance>
public sealed class InstanceController : ApiController
{
/// <summary>
/// File name to allow attaching instances
@@ -72,7 +72,7 @@ namespace Tgstation.Server.Host.Controllers
/// <param name="application">The value of <see cref="application"/></param>
/// <param name="platformIdentifier">The value of <see cref="platformIdentifier"/></param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="ApiController"/></param>
public InstanceController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IJobManager jobManager, IInstanceManager instanceManager, IIOManager ioManager, IApplication application, IPlatformIdentifier platformIdentifier, ILogger<InstanceController> logger) : base(databaseContext, authenticationContextFactory, logger, false)
public InstanceController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IJobManager jobManager, IInstanceManager instanceManager, IIOManager ioManager, IApplication application, IPlatformIdentifier platformIdentifier, ILogger<InstanceController> 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
};
/// <inheritdoc />
/// <summary>
/// Create or attach an <see cref="Api.Models.Instance"/>.
/// </summary>
/// <param name="model">The <see cref="Api.Models.Instance"/> settings.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the request.</returns>
/// <response code="200">Instance attached successfully.</response>
/// <response code="201">Instance created successfully.</response>
[HttpPut]
[TgsAuthorize(InstanceManagerRights.Create)]
public override async Task<IActionResult> Create([FromBody] Api.Models.Instance model, CancellationToken cancellationToken)
[ProducesResponseType(typeof(Api.Models.Instance), 200)]
[ProducesResponseType(typeof(Api.Models.Instance), 201)]
public async Task<IActionResult> 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);
}
/// <inheritdoc />
/// <summary>
/// Detach an <see cref="Api.Models.Instance"/> with the given <paramref name="id"/>.
/// </summary>
/// <param name="id">The <see cref="Api.Models.Instance.Id"/> to detach.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the request.</returns>
/// <response code="200">Instance detatched successfully.</response>
/// <response code="410">Instance not available.</response>
[HttpDelete("{id}")]
[TgsAuthorize(InstanceManagerRights.Delete)]
public override async Task<IActionResult> Delete(long id, CancellationToken cancellationToken)
[ProducesResponseType(200)]
[ProducesResponseType(410)]
public async Task<IActionResult> 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();
}
/// <inheritdoc />
/// <summary>
/// Modify an <see cref="Api.Models.Instance"/>'s settings.
/// </summary>
/// <param name="model">The updated <see cref="Api.Models.Instance"/> settings.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the request.</returns>
/// <response code="200">Instance updated successfully.</response>
/// <response code="202">Instance updated successfully and relocation job created.</response>
[HttpPost]
[TgsAuthorize(InstanceManagerRights.Relocate | InstanceManagerRights.Rename | InstanceManagerRights.SetAutoUpdate | InstanceManagerRights.SetConfiguration | InstanceManagerRights.SetOnline)]
#pragma warning disable CA1502 // TODO: Decomplexify
public override async Task<IActionResult> 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<IActionResult> 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
/// <inheritdoc />
/// <summary>
/// List <see cref="Api.Models.Instance"/>s.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the request.</returns>
/// <response code="200">Retrieved <see cref="Api.Models.Instance"/>s successfully.</response>
[HttpGet(Routes.List)]
[TgsAuthorize(InstanceManagerRights.List | InstanceManagerRights.Read)]
public override async Task<IActionResult> List(CancellationToken cancellationToken)
[ProducesResponseType(typeof(IEnumerable<Api.Models.Instance>), 200)]
public async Task<IActionResult> List(CancellationToken cancellationToken)
{
IQueryable<Models.Instance> query = DatabaseContext.Instances;
if (!AuthenticationContext.User.InstanceManagerRights.Value.HasFlag(InstanceManagerRights.List))
@@ -418,9 +467,19 @@ namespace Tgstation.Server.Host.Controllers
return Json(apis);
}
/// <inheritdoc />
/// <summary>
/// Get a specific <see cref="Api.Models.Instance"/>.
/// </summary>
/// <param name="id">The <see cref="Api.Models.Instance.Id"/> to retrieve.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the request.</returns>
/// <response code="200">Retrieved <see cref="Api.Models.Instance"/> successfully.</response>
/// <response code="410">Instance not available.</response>
[HttpGet("{id}")]
[TgsAuthorize(InstanceManagerRights.List | InstanceManagerRights.Read)]
public override async Task<IActionResult> GetId(long id, CancellationToken cancellationToken)
[ProducesResponseType(typeof(Api.Models.Instance), 200)]
[ProducesResponseType(410)]
public async Task<IActionResult> GetId(long id, CancellationToken cancellationToken)
{
var query = DatabaseContext.Instances.Where(x => x.Id == id);
var cantList = !AuthenticationContext.User.InstanceManagerRights.Value.HasFlag(InstanceManagerRights.List);
@@ -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
{
/// <summary>
/// For managing <see cref="User"/>s
/// <see cref="ApiController"/> for managing <see cref="InstanceUser"/>s.
/// </summary>
[Route(Routes.InstanceUser)]
public sealed class InstanceUserController : ModelController<Api.Models.InstanceUser>
public sealed class InstanceUserController : ApiController
{
/// <summary>
/// Construct a <see cref="UserController"/>
@@ -27,7 +28,7 @@ namespace Tgstation.Server.Host.Controllers
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> for the <see cref="ApiController"/></param>
/// <param name="authenticationContextFactory">The <see cref="IAuthenticationContextFactory"/> for the <see cref="ApiController"/></param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="ApiController"/></param>
public InstanceUserController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, ILogger<InstanceUserController> logger) : base(databaseContext, authenticationContextFactory, logger, true) // false instance requirement, we handle this ourself
public InstanceUserController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, ILogger<InstanceUserController> logger) : base(databaseContext, authenticationContextFactory, logger, true, true)
{ }
/// <summary>
@@ -46,9 +47,17 @@ namespace Tgstation.Server.Host.Controllers
return null;
}
/// <inheritdoc />
/// <summary>
/// Create am <see cref="Api.Models.InstanceUser"/>.
/// </summary>
/// <param name="model">The <see cref="Api.Models.InstanceUser"/> to create.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the request.</returns>
/// <response code="201"><see cref="Api.Models.InstanceUser"/> created successfully.</response>
[HttpPut]
[TgsAuthorize(InstanceUserRights.CreateUsers)]
public override async Task<IActionResult> Create([FromBody] Api.Models.InstanceUser model, CancellationToken cancellationToken)
[ProducesResponseType(typeof(Api.Models.InstanceUser), 201)]
public async Task<IActionResult> 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());
}
/// <inheritdoc />
/// <summary>
/// Update the permissions for an <see cref="Api.Models.InstanceUser"/>.
/// </summary>
/// <param name="model">The updated <see cref="Api.Models.InstanceUser"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the request.</returns>
/// <response code="200"><see cref="Api.Models.InstanceUser"/> updated successfully.</response>
/// <response code="410">Instance user unavailable.</response>
[HttpPost]
[TgsAuthorize(InstanceUserRights.WriteUsers)]
[ProducesResponseType(typeof(Api.Models.InstanceUser), 200)]
[ProducesResponseType(410)]
#pragma warning disable CA1506 // TODO: Decomplexify
public override async Task<IActionResult> Update([FromBody] Api.Models.InstanceUser model, CancellationToken cancellationToken)
public async Task<IActionResult> 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
/// <inheritdoc />
#pragma warning restore CA1506
/// <summary>
/// Read the active <see cref="Api.Models.InstanceUser"/>.
/// </summary>
/// <returns>The <see cref="IActionResult"/> of the request.</returns>
/// <response code="200"><see cref="Api.Models.InstanceUser"/> retrieved successfully.</response>
[HttpGet]
[TgsAuthorize]
public override Task<IActionResult> 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());
/// <inheritdoc />
/// <summary>
/// Lists <see cref="Api.Models.InstanceUser"/>s for the instance.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the request.</returns>
/// <response code="200">Retrieved <see cref="Api.Models.InstanceUser"/>s successfully.</response>
[HttpGet(Routes.List)]
[TgsAuthorize(InstanceUserRights.ReadUsers)]
public override async Task<IActionResult> List(CancellationToken cancellationToken)
[ProducesResponseType(typeof(IEnumerable<Api.Models.InstanceUser>), 200)]
public async Task<IActionResult> 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()));
}
/// <inheritdoc />
/// <summary>
/// Gets a specific <see cref="Api.Models.InstanceUser"/>.
/// </summary>
/// <param name="id">The <see cref="Api.Models.InstanceUser.UserId"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the request.</returns>
/// <response code="200">Retrieve <see cref="Api.Models.InstanceUser"/> successfully.</response>
/// <response code="410">Instance user unavailable.</response>
[HttpGet("{id}")]
[TgsAuthorize(InstanceUserRights.ReadUsers)]
public override async Task<IActionResult> GetId(long id, CancellationToken cancellationToken)
[ProducesResponseType(typeof(Api.Models.InstanceUser), 200)]
[ProducesResponseType(410)]
public async Task<IActionResult> 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());
}
/// <inheritdoc />
/// <summary>
/// Delete an <see cref="Api.Models.InstanceUser"/>.
/// </summary>
/// <param name="id">The <see cref="Api.Models.InstanceUser.UserId"/> to delete.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the request.</returns>
/// <response code="200"><see cref="Api.Models.InstanceUser"/> deleted or no longer exists.</response>
[HttpDelete("{id}")]
[TgsAuthorize(InstanceUserRights.WriteUsers)]
public override async Task<IActionResult> Delete(long id, CancellationToken cancellationToken)
[ProducesResponseType(200)]
public async Task<IActionResult> 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();
@@ -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
{
/// <summary>
/// <see cref="ModelController{TModel}"/> for <see cref="Api.Models.Job"/>s
/// <see cref="ApiController"/> for <see cref="Api.Models.Job"/>s
/// </summary>
[Route(Routes.Jobs)]
public sealed class JobController : ModelController<Api.Models.Job>
public sealed class JobController : ApiController
{
/// <summary>
/// The <see cref="IJobManager"/> for the <see cref="JobController"/>
@@ -31,37 +32,63 @@ namespace Tgstation.Server.Host.Controllers
/// <param name="authenticationContextFactory">The <see cref="IAuthenticationContextFactory"/> for the <see cref="ApiController"/></param>
/// <param name="jobManager">The value of <see cref="jobManager"/></param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="ApiController"/></param>
public JobController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IJobManager jobManager, ILogger<JobController> logger) : base(databaseContext, authenticationContextFactory, logger, true)
public JobController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IJobManager jobManager, ILogger<JobController> logger) : base(databaseContext, authenticationContextFactory, logger, true, true)
{
this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager));
}
/// <inheritdoc />
/// <summary>
/// Get active <see cref="Api.Models.Job"/>s for the instance.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the request.</returns>
/// <response code="200">Retrieved active <see cref="Api.Models.Job"/>s successfully.</response>
[HttpGet]
[TgsAuthorize]
public override async Task<IActionResult> Read(CancellationToken cancellationToken)
[ProducesResponseType(typeof(IEnumerable<Api.Models.Job>), 200)]
public async Task<IActionResult> 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()));
}
/// <inheritdoc />
/// <summary>
/// List all <see cref="Api.Models.Job"/> <see cref="Api.Models.EntityId"/>s for the instance in reverse creation order.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the request.</returns>
/// <response code="200">Retrieved <see cref="Api.Models.Job"/> <see cref="Api.Models.EntityId"/>s successfully.</response>
[HttpGet(Routes.List)]
[TgsAuthorize]
public override async Task<IActionResult> List(CancellationToken cancellationToken)
[ProducesResponseType(typeof(List<Api.Models.EntityId>), 200)]
public async Task<IActionResult> 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);
}
/// <inheritdoc />
/// <summary>
/// Cancel a running <see cref="Api.Models.Job"/>.
/// </summary>
/// <param name="id">The <see cref="Api.Models.EntityId.Id"/> of the <see cref="Api.Models.Job"/> to cancel.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the request.</returns>
/// <response code="202"><see cref="Api.Models.Job"/> cancellation requested successfully.</response>
/// <response code="404"><see cref="Api.Models.Job"/> does not exist in this instance.</response>
/// <response code="410"><see cref="Api.Models.Job"/> already cancelled or completed.</response>
[HttpDelete("{id}")]
[TgsAuthorize]
public override async Task<IActionResult> Delete(long id, CancellationToken cancellationToken)
[ProducesResponseType(202)]
[ProducesResponseType(404)]
[ProducesResponseType(410)]
public async Task<IActionResult> 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);
}
/// <inheritdoc />
/// <summary>
/// Get a specific <see cref="Api.Models.Job"/>.
/// </summary>
/// <param name="id">The <see cref="Api.Models.EntityId.Id"/> of the <see cref="Api.Models.Job"/> to retrieve.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the request.</returns>
/// <response code="200">Retrieved <see cref="Api.Models.Job"/> successfully.</response>
/// <response code="404"><see cref="Api.Models.Job"/> does not exist in this instance.</response>
[HttpGet("{id}")]
[TgsAuthorize]
public override async Task<IActionResult> GetId(long id, CancellationToken cancellationToken)
[ProducesResponseType(typeof(Api.Models.Job), 200)]
[ProducesResponseType(404)]
public async Task<IActionResult> 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();
@@ -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
{
/// <summary>
/// An <see cref="ApiController"/> representing a <typeparamref name="TModel"/>
/// </summary>
/// <typeparam name="TModel">The model being represented</typeparam>
public abstract class ModelController<TModel> : ApiController where TModel : class
{
/// <summary>
/// Construct a <see cref="ModelController{TModel}"/>
/// </summary>
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> for the <see cref="ApiController"/></param>
/// <param name="authenticationContextFactory">The <see cref="IAuthenticationContextFactory"/> for the <see cref="ApiController"/></param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="ApiController"/></param>
/// <param name="requireInstance">If the <see cref="ModelController{TModel}"/> requires an <see cref="IAuthenticationContext.InstanceUser"/></param>
public ModelController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, ILogger logger, bool requireInstance) : base(databaseContext, authenticationContextFactory, logger, requireInstance, true) { }
/// <summary>
/// Attempt to create a <paramref name="model"/>
/// </summary>
/// <param name="model">The <typeparamref name="TModel"/> being created</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the operation</returns>
[HttpPut]
public virtual Task<IActionResult> Create([FromBody]TModel model, CancellationToken cancellationToken) => Task.FromResult((IActionResult)NotFound());
/// <summary>
/// Attempt to read a <typeparamref name="TModel"/>
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the operation</returns>
[HttpGet]
public virtual Task<IActionResult> Read(CancellationToken cancellationToken) => Task.FromResult((IActionResult)NotFound());
/// <summary>
/// Attempt to get a specific a <typeparamref name="TModel"/>
/// </summary>
/// <param name="id">The ID of the model to get</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the operation</returns>
[HttpGet("{id}")]
public virtual Task<IActionResult> GetId(long id, CancellationToken cancellationToken) => Task.FromResult((IActionResult)NotFound());
/// <summary>
/// Attempt to update a <paramref name="model"/>
/// </summary>
/// <param name="model">The <typeparamref name="TModel"/> being updated</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the operation</returns>
[HttpPost]
public virtual Task<IActionResult> Update([FromBody]TModel model, CancellationToken cancellationToken) => Task.FromResult((IActionResult)NotFound());
/// <summary>
/// Attempt to delete a model with a particular <paramref name="id"/>
/// </summary>
/// <param name="id">The ID of the model to delete</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the operation</returns>
[HttpDelete("{id}")]
public virtual Task<IActionResult> Delete(long id, CancellationToken cancellationToken) => Task.FromResult((IActionResult)NotFound());
/// <summary>
/// Attempt to list entries of the <typeparamref name="TModel"/>
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the operation</returns>
[HttpGet("List")]
public virtual Task<IActionResult> List(CancellationToken cancellationToken) => Task.FromResult((IActionResult)NotFound());
}
}
@@ -23,11 +23,11 @@ using Tgstation.Server.Host.Security;
namespace Tgstation.Server.Host.Controllers
{
/// <summary>
/// Controller for managing the <see cref="Repository"/>s
/// <see cref="ApiController"/> for managing the <see cref="Repository"/>s
/// </summary>
[Route(Routes.Repository)]
#pragma warning disable CA1506 // TODO: Decomplexify
public sealed class RepositoryController : ModelController<Repository>
public sealed class RepositoryController : ApiController
{
/// <summary>
/// The <see cref="IInstanceManager"/> for the <see cref="RepositoryController"/>
@@ -59,7 +59,7 @@ namespace Tgstation.Server.Host.Controllers
/// <param name="jobManager">The value of <see cref="jobManager"/></param>
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="ApiController"/></param>
/// <param name="generalConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing value of <see cref="generalConfiguration"/></param>
public RepositoryController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IInstanceManager instanceManager, IGitHubClientFactory gitHubClientFactory, IJobManager jobManager, ILogger<RepositoryController> logger, IOptions<GeneralConfiguration> generalConfigurationOptions) : base(databaseContext, authenticationContextFactory, logger, true)
public RepositoryController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IInstanceManager instanceManager, IGitHubClientFactory gitHubClientFactory, IJobManager jobManager, ILogger<RepositoryController> logger, IOptions<GeneralConfiguration> 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;
}
/// <inheritdoc />
/// <summary>
/// Begin cloning the repository if it doesn't exist.
/// </summary>
/// <param name="model">Initial <see cref="Repository"/> settings.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the request.</returns>
/// <response code="201">The <see cref="Repository"/> was created successfully and the <see cref="Api.Models.Job"/> to clone it has begun.</response>
/// <response code="410">Instance no longer available.</response>
[HttpPut]
[TgsAuthorize(RepositoryRights.SetOrigin)]
public override async Task<IActionResult> Create([FromBody] Repository model, CancellationToken cancellationToken)
[ProducesResponseType(typeof(Repository), 201)]
[ProducesResponseType(410)]
public async Task<IActionResult> Create([FromBody] Repository model, CancellationToken cancellationToken)
{
if (model == null)
throw new ArgumentNullException(nameof(model));
@@ -214,11 +224,16 @@ namespace Tgstation.Server.Host.Controllers
}
/// <summary>
/// Delete the <see cref="Repository"/>
/// Delete the <see cref="Repository"/>.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the operation</returns>
/// <response code="202">Job to delete the repository created successfully.</response>
/// <response code="410">Instance no longer available.</response>
[HttpDelete("{id}")]
[TgsAuthorize(RepositoryRights.Delete)]
[ProducesResponseType(typeof(Repository), 202)]
[ProducesResponseType(410)]
public async Task<IActionResult> 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);
}
/// <inheritdoc />
/// <summary>
/// Get <see cref="Repository"/> status.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the operation.</returns>
/// <response code="200">Retrieved the <see cref="Repository"/> settings successfully.</response>
/// <response code="201">Retrieved the <see cref="Repository"/> settings successfully, though they did not previously exist.</response>
/// <response code="410">Instance no longer available.</response>
[HttpGet]
[TgsAuthorize(RepositoryRights.Read)]
public override async Task<IActionResult> Read(CancellationToken cancellationToken)
[ProducesResponseType(typeof(Repository), 200)]
[ProducesResponseType(typeof(Repository), 201)]
[ProducesResponseType(410)]
public async Task<IActionResult> 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
}
}
/// <inheritdoc />
/// <summary>
/// Perform updats to the <see cref="Repository"/>.
/// </summary>
/// <param name="model">The updated <see cref="Repository"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the operation.</returns>
/// <response code="200">Updated the <see cref="Repository"/> settings successfully.</response>
/// <response code="201">Updated the <see cref="Repository"/> settings successfully and a <see cref="Api.Models.Job"/> was created to make the requested git changes.</response>
/// <response code="410">Instance no longer available.</response>
[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<IActionResult> Update([FromBody]Repository model, CancellationToken cancellationToken)
public async Task<IActionResult> Update([FromBody]Repository model, CancellationToken cancellationToken)
{
if (model == null)
throw new ArgumentNullException(nameof(model));
@@ -11,6 +11,11 @@ namespace Tgstation.Server.Host.Controllers
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
sealed class TgsAuthorizeAttribute : AuthorizeAttribute
{
/// <summary>
/// Gets the <see cref="Api.Rights.RightsType"/> associated with the <see cref="TgsAuthorizeAttribute"/> if any.
/// </summary>
public RightsType? RightsType { get; }
/// <summary>
/// Construct a <see cref="TgsAuthorizeAttribute"/>
/// </summary>
@@ -23,6 +28,7 @@ namespace Tgstation.Server.Host.Controllers
public TgsAuthorizeAttribute(AdministrationRights requiredRights)
{
Roles = RightsHelper.RoleNames(requiredRights);
RightsType = Api.Rights.RightsType.Administration;
}
/// <summary>
@@ -32,6 +38,7 @@ namespace Tgstation.Server.Host.Controllers
public TgsAuthorizeAttribute(InstanceManagerRights requiredRights)
{
Roles = RightsHelper.RoleNames(requiredRights);
RightsType = Api.Rights.RightsType.InstanceManager;
}
/// <summary>
@@ -41,6 +48,7 @@ namespace Tgstation.Server.Host.Controllers
public TgsAuthorizeAttribute(RepositoryRights requiredRights)
{
Roles = RightsHelper.RoleNames(requiredRights);
RightsType = Api.Rights.RightsType.Repository;
}
/// <summary>
@@ -50,6 +58,7 @@ namespace Tgstation.Server.Host.Controllers
public TgsAuthorizeAttribute(ByondRights requiredRights)
{
Roles = RightsHelper.RoleNames(requiredRights);
RightsType = Api.Rights.RightsType.Byond;
}
/// <summary>
@@ -59,6 +68,7 @@ namespace Tgstation.Server.Host.Controllers
public TgsAuthorizeAttribute(DreamMakerRights requiredRights)
{
Roles = RightsHelper.RoleNames(requiredRights);
RightsType = Api.Rights.RightsType.DreamMaker;
}
/// <summary>
@@ -68,6 +78,7 @@ namespace Tgstation.Server.Host.Controllers
public TgsAuthorizeAttribute(DreamDaemonRights requiredRights)
{
Roles = RightsHelper.RoleNames(requiredRights);
RightsType = Api.Rights.RightsType.DreamDaemon;
}
/// <summary>
@@ -77,6 +88,7 @@ namespace Tgstation.Server.Host.Controllers
public TgsAuthorizeAttribute(ChatBotRights requiredRights)
{
Roles = RightsHelper.RoleNames(requiredRights);
RightsType = Api.Rights.RightsType.ChatBots;
}
/// <summary>
@@ -86,6 +98,7 @@ namespace Tgstation.Server.Host.Controllers
public TgsAuthorizeAttribute(ConfigurationRights requiredRights)
{
Roles = RightsHelper.RoleNames(requiredRights);
RightsType = Api.Rights.RightsType.Configuration;
}
/// <summary>
@@ -95,6 +108,7 @@ namespace Tgstation.Server.Host.Controllers
public TgsAuthorizeAttribute(InstanceUserRights requiredRights)
{
Roles = RightsHelper.RoleNames(requiredRights);
RightsType = Api.Rights.RightsType.InstanceUser;
}
}
}
@@ -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
{
/// <summary>
/// <see cref="IOperationFilter"/> and <see cref="IDocumentFilter"/> for the server.
/// </summary>
sealed class TgsOpenApiFilters : IOperationFilter, IDocumentFilter
{
/// <summary>
/// The <see cref="OpenApiSecurityScheme"/> name for password authentication.
/// </summary>
public const string PasswordSecuritySchemeId = "Password_Login_Scheme";
/// <summary>
/// The <see cref="OpenApiSecurityScheme"/> name for token authentication.
/// </summary>
public const string TokenSecuritySchemeId = "Token_Authorization_Scheme";
/// <inheritdoc />
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<TgsAuthorizeAttribute>();
if (authAttributes.Any())
{
var tokenScheme = new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = TokenSecuritySchemeId
}
};
operation.Security = new List<OpenApiSecurityRequirement>
{
new OpenApiSecurityRequirement
{
{
tokenScheme,
new List<string>()
}
}
};
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<OpenApiSecurityRequirement>
{
new OpenApiSecurityRequirement
{
{
passwordScheme,
new List<string>()
}
}
};
}
}
/// <inheritdoc />
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<string, OpenApiMediaType>
{
{
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."
});
}
}
}
@@ -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
{
/// <summary>
/// For managing <see cref="User"/>s
/// <see cref="ApiController"/> for managing <see cref="User"/>s.
/// </summary>
[Route(Routes.User)]
public sealed class UserController : ModelController<UserUpdate>
public sealed class UserController : ApiController
{
/// <summary>
/// The <see cref="ISystemIdentityFactory"/> for the <see cref="UserController"/>
@@ -53,7 +52,7 @@ namespace Tgstation.Server.Host.Controllers
/// <param name="cryptographySuite">The value of <see cref="cryptographySuite"/></param>
/// <param name="logger">The value of <see cref="logger"/></param>
/// <param name="generalConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the value of <see cref="generalConfiguration"/></param>
public UserController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, ISystemIdentityFactory systemIdentityFactory, ICryptographySuite cryptographySuite, ILogger<UserController> logger, IOptions<GeneralConfiguration> generalConfigurationOptions) : base(databaseContext, authenticationContextFactory, logger, false)
public UserController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, ISystemIdentityFactory systemIdentityFactory, ICryptographySuite cryptographySuite, ILogger<UserController> logger, IOptions<GeneralConfiguration> 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));
}
/// <inheritdoc />
/// <summary>
/// Check if a given <paramref name="model"/> has a valid <see cref="Api.Models.Internal.User.Name"/> specified.
/// </summary>
/// <param name="model">The <see cref="UserUpdate"/> to check.</param>
/// <returns><see langword="null"/> if <paramref name="model"/> is valid, a <see cref="BadRequestObjectResult"/> otherwise.</returns>
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;
}
/// <summary>
/// Attempt to change the password of a given <paramref name="dbUser"/>.
/// </summary>
/// <param name="dbUser">The <see cref="Models.User"/> to update.</param>
/// <param name="newPassword">The new password.</param>
/// <returns><see langword="null"/> on success, <see cref="BadRequestObjectResult"/> if <paramref name="newPassword"/> is too short.</returns>
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;
}
/// <summary>
/// Create a <see cref="Api.Models.User"/>.
/// </summary>
/// <param name="model">The <see cref="Api.Models.User"/> to create.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the operation.</returns>
/// <response code="201"><see cref="Api.Models.User"/> created successfully.</response>
/// <response code="410">The <see cref="Api.Models.Internal.User.SystemIdentifier"/> requested could not be loaded.</response>
/// <response code="501">A system user was requested but this is not implemented on POSIX.</response>
[HttpPut]
[TgsAuthorize(AdministrationRights.WriteUsers)]
public override async Task<IActionResult> Create([FromBody] UserUpdate model, CancellationToken cancellationToken)
[ProducesResponseType(typeof(Api.Models.User), 201)]
[ProducesResponseType(410)]
[ProducesResponseType(501)]
public async Task<IActionResult> 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));
}
/// <inheritdoc />
/// <summary>
/// Update a <see cref="Api.Models.User"/>.
/// </summary>
/// <param name="model">The <see cref="Api.Models.User"/> to update.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the operation.</returns>
/// <response code="200"><see cref="Api.Models.User"/> updated successfully.</response>
/// <response code="404">Requested <see cref="Api.Models.Internal.User.Id"/> does not exist.</response>
[HttpPost]
[TgsAuthorize(AdministrationRights.WriteUsers | AdministrationRights.EditOwnPassword)]
public override async Task<IActionResult> Update([FromBody] UserUpdate model, CancellationToken cancellationToken)
[ProducesResponseType(typeof(Api.Models.User), 200)]
[ProducesResponseType(404)]
public async Task<IActionResult> 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
});
}
/// <inheritdoc />
/// <summary>
/// Get information about the current <see cref="Api.Models.User"/>.
/// </summary>
/// <returns>The <see cref="IActionResult"/> of the operation.</returns>
/// <response code="200">The <see cref="Api.Models.User"/> was retrieved successfully.</response>
[HttpGet]
[TgsAuthorize]
public override Task<IActionResult> Read(CancellationToken cancellationToken) => Task.FromResult<IActionResult>(Json(AuthenticationContext.User.ToApi(true)));
[ProducesResponseType(typeof(Api.Models.User), 200)]
public IActionResult Read() => Json(AuthenticationContext.User.ToApi(true));
/// <inheritdoc />
/// <summary>
/// List all <see cref="Api.Models.User"/>s in the server.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the operation.</returns>
/// <response code="200">Retrieved <see cref="Api.Models.User"/>s successfully.</response>
[HttpGet(Routes.List)]
[TgsAuthorize(AdministrationRights.ReadUsers)]
public override async Task<IActionResult> List(CancellationToken cancellationToken)
[ProducesResponseType(typeof(IEnumerable<Api.Models.User>), 200)]
public async Task<IActionResult> 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)));
}
/// <inheritdoc />
/// <summary>
/// Get a specific <see cref="Api.Models.User"/>.
/// </summary>
/// <param name="id">The <see cref="Api.Models.Internal.User.Id"/> to retrieve.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IActionResult"/> of the operation.</returns>
/// <response code="200">The <see cref="Api.Models.User"/> was retrieved successfully.</response>
/// <response code="404">The <see cref="Api.Models.User"/> does not exist.</response>
[HttpGet("{id}")]
[TgsAuthorize]
public override async Task<IActionResult> GetId(long id, CancellationToken cancellationToken)
[ProducesResponseType(typeof(Api.Models.User), 200)]
[ProducesResponseType(404)]
public async Task<IActionResult> 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();
+85 -15
View File
@@ -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
{
/// <inheritdoc />
#pragma warning disable CA1506
#pragma warning disable CA1506
sealed class Application : IApplication
{
/// <inheritdoc />
@@ -51,6 +56,11 @@ namespace Tgstation.Server.Host.Core
/// </summary>
readonly IConfiguration configuration;
/// <summary>
/// The <see cref="IAssemblyInformationProvider"/> for the <see cref="Application"/>.
/// </summary>
readonly IAssemblyInformationProvider assemblyInformationProvider;
/// <summary>
/// The <see cref="Microsoft.AspNetCore.Hosting.IHostingEnvironment"/> for the <see cref="Application"/>
/// </summary>
@@ -70,15 +80,20 @@ namespace Tgstation.Server.Host.Core
/// Construct an <see cref="Application"/>
/// </summary>
/// <param name="configuration">The value of <see cref="configuration"/></param>
/// <param name="assemblyInformationProvider">The <see cref="IAssemblyInformationProvider"/> for the <see cref="Application"/>.</param>
/// <param name="hostingEnvironment">The value of <see cref="hostingEnvironment"/></param>
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<object>();
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<TgsOpenApiFilters>();
c.DocumentFilter<TgsOpenApiFilters>();
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<CorsPolicyBuilder> corsBuilder = null;
if (controlPanelConfiguration.AllowAnyOrigin)
@@ -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
}
});
}
/// <summary>
/// Suppress all in flight exceptions with error 500.
/// </summary>
/// <param name="applicationBuilder">The <see cref="IApplicationBuilder"/> to configure</param>
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);
}
});
}
}
}
@@ -0,0 +1,24 @@
using System.Reflection;
namespace Tgstation.Server.Host.Core
{
/// <inheritdoc />
sealed class AssemblyInformationProvider : IAssemblyInformationProvider
{
/// <inheritdoc />
public string Path { get; }
/// <inheritdoc />
public AssemblyName Name { get; }
/// <summary>
/// Initializes a new instance of the <see cref="AssemblyInformationProvider"/> <see langword="class"/>.
/// </summary>
public AssemblyInformationProvider()
{
Assembly assembly = Assembly.GetExecutingAssembly();
Path = assembly.Location;
Name = assembly.GetName();
}
}
}
@@ -0,0 +1,20 @@
using System.Reflection;
namespace Tgstation.Server.Host.Core
{
/// <summary>
/// For retrieving the <see cref="Assembly"/>'s location.
/// </summary>
interface IAssemblyInformationProvider
{
/// <summary>
/// Gets the path to the executing assembly.
/// </summary>
string Path { get; }
/// <summary>
/// Gets the <see cref="AssemblyName"/>.
/// </summary>
AssemblyName Name { get; }
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ namespace Tgstation.Server.Host.Core
readonly ILogger<JobManager> logger;
/// <summary>
/// <see cref="Dictionary{TKey, TValue}"/> of <see cref="Api.Models.Internal.Job.Id"/> to running <see cref="JobHandler"/>s
/// <see cref="Dictionary{TKey, TValue}"/> of <see cref="Job"/> <see cref="Api.Models.EntityId.Id"/>s to running <see cref="JobHandler"/>s
/// </summary>
readonly Dictionary<long, JobHandler> jobs;
+83 -32
View File
@@ -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 [<server>:<port> or <server>] (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, @"^(?<server>.+):(?<port>.+)$");
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;
}
}
+40 -1
View File
@@ -6,11 +6,50 @@ using System.Threading.Tasks;
namespace Tgstation.Server.Host.IO
{
/// <inheritdoc />
sealed class Console : IConsole
sealed class Console : IConsole, IDisposable
{
/// <inheritdoc />
public bool Available => Environment.UserInteractive;
/// <inheritdoc />
public CancellationToken CancelKeyPress => cancelKeyCts.Token;
/// <summary>
/// The <see cref="CancellationTokenSource"/> for <see cref="CancelKeyPress"/>.
/// </summary>
readonly CancellationTokenSource cancelKeyCts;
/// <summary>
/// If the <see cref="Console"/> was disposed;
/// </summary>
bool disposed;
/// <summary>
/// Initializes a new instance of the <see cref="Console"/> <see langword="class"/>.
/// </summary>
public Console()
{
cancelKeyCts = new CancellationTokenSource();
System.Console.CancelKeyPress += (sender, e) =>
{
lock (cancelKeyCts)
{
if (!disposed)
cancelKeyCts.Cancel();
}
};
}
/// <inheritdoc />
public void Dispose()
{
lock (cancelKeyCts)
{
cancelKeyCts.Dispose();
disposed = true;
}
}
void CheckAvailable()
{
if (!Available)
+5
View File
@@ -13,6 +13,11 @@ namespace Tgstation.Server.Host.IO
/// </summary>
bool Available { get; }
/// <summary>
/// Gets a <see cref="CancellationToken"/> that triggers if Crtl+C or an equivalent is pressed.
/// </summary>
CancellationToken CancelKeyPress { get; }
/// <summary>
/// Write some <paramref name="text"/> to the <see cref="IConsole"/>
/// </summary>
@@ -13,7 +13,7 @@ namespace Tgstation.Server.Host.Models
public Job Job { get; set; }
/// <summary>
/// The <see cref="Api.Models.Internal.Job.Id"/> of <see cref="Job"/>
/// The <see cref="Api.Models.EntityId.Id"/> of <see cref="Job"/>
/// </summary>
public long JobId { get; set; }
@@ -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<DatabaseConfiguration> 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();
+1 -1
View File
@@ -16,7 +16,7 @@ namespace Tgstation.Server.Host
/// The <see cref="IServerFactory"/> to use
/// </summary>
#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
/// <summary>
@@ -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
/// </summary>
/// <param name="asyncDelayer">The value of <see cref="asyncDelayer"/></param>
/// <param name="cryptographySuite">The <see cref="ICryptographySuite"/> used for generating the <see cref="ValidationParameters"/></param>
public TokenFactory(IAsyncDelayer asyncDelayer, ICryptographySuite cryptographySuite)
/// <param name="assemblyInformationProvider">The <see cref="IAssemblyInformationProvider"/> used to generate the issuer name.</param>
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,
+8 -1
View File
@@ -30,6 +30,11 @@ namespace Tgstation.Server.Host
/// </summary>
readonly IWebHostBuilder webHostBuilder;
/// <summary>
/// The <see cref="IIOManager"/> for the <see cref="Server"/>.
/// </summary>
readonly IIOManager ioManager;
/// <summary>
/// The <see cref="IRestartHandler"/>s to run when the <see cref="Server"/> restarts
/// </summary>
@@ -69,10 +74,12 @@ namespace Tgstation.Server.Host
/// Construct a <see cref="Server"/>
/// </summary>
/// <param name="webHostBuilder">The value of <see cref="webHostBuilder"/></param>
/// <param name="ioManager">The value of <see cref="ioManager"/>.</param>
/// <param name="updatePath">The value of <see cref="updatePath"/></param>
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<IServerControl>(this));
+39 -3
View File
@@ -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
{
/// <inheritdoc />
public sealed class ServerFactory : IServerFactory
{
/// <summary>
/// The <see cref="IAssemblyInformationProvider"/> for the <see cref="ServerFactory"/>.
/// </summary>
readonly IAssemblyInformationProvider assemblyInformationProvider;
/// <summary>
/// The <see cref="IIOManager"/> for the <see cref="ServerFactory"/>.
/// </summary>
readonly IIOManager ioManager;
/// <summary>
/// Create the default <see cref="IServerFactory"/>.
/// </summary>
/// <returns>A new <see cref="IServerFactory"/> with the default settings.</returns>
public static IServerFactory CreateDefault()
=> new ServerFactory(
new AssemblyInformationProvider(),
new DefaultIOManager());
/// <summary>
/// Initializes a new instance of the <see cref="ServerFactory"/>.
/// </summary>
/// <param name="assemblyInformationProvider">The value of <see cref="assemblyInformationProvider"/>.</param>
/// <param name="ioManager">The value of <see cref="ioManager"/>.</param>
internal ServerFactory(IAssemblyInformationProvider assemblyInformationProvider, IIOManager ioManager)
{
this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider));
this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager));
}
/// <inheritdoc />
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<Application>()
.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);
}
}
}
@@ -83,6 +83,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="5.0.0-rc5" />
<PackageReference Include="System.Diagnostics.PerformanceCounter" Version="4.7.0" />
<PackageReference Include="System.DirectoryServices.AccountManagement" Version="4.7.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="5.6.0" />
@@ -14,22 +14,22 @@ namespace Tgstation.Server.Host.Components.Byond.Tests
public void TestConstruction()
{
Assert.ThrowsException<ArgumentNullException>(() => new PosixByondInstaller(null, null, null));
var mockIOManager = new Mock<IIOManager>();
Assert.ThrowsException<ArgumentNullException>(() => new PosixByondInstaller(mockIOManager.Object, null, null));
var mockPostWriteHandler = new Mock<IPostWriteHandler>();
Assert.ThrowsException<ArgumentNullException>(() => new PosixByondInstaller(mockIOManager.Object, mockPostWriteHandler.Object, null));
Assert.ThrowsException<ArgumentNullException>(() => new PosixByondInstaller(mockPostWriteHandler.Object, null, null));
var mockIOManager = new Mock<IIOManager>();
Assert.ThrowsException<ArgumentNullException>(() => new PosixByondInstaller(mockPostWriteHandler.Object, mockIOManager.Object, null));
var mockLogger = new Mock<ILogger<PosixByondInstaller>>();
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<IIOManager>();
var mockPostWriteHandler = new Mock<IPostWriteHandler>();
var mockIOManager = new Mock<IIOManager>();
var mockLogger = new Mock<ILogger<PosixByondInstaller>>();
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<IIOManager>();
var mockPostWriteHandler = new Mock<IPostWriteHandler>();
var mockLogger = new Mock<ILogger<PosixByondInstaller>>();
var installer = new PosixByondInstaller(mockIOManager.Object, mockPostWriteHandler.Object, mockLogger.Object);
var installer = new PosixByondInstaller(mockPostWriteHandler.Object, mockIOManager.Object, mockLogger.Object);
await Assert.ThrowsExceptionAsync<ArgumentNullException>(() => installer.DownloadVersion(null, default)).ConfigureAwait(false);
@@ -79,7 +79,7 @@ namespace Tgstation.Server.Host.Components.Byond.Tests
var mockIOManager = new Mock<IIOManager>();
var mockPostWriteHandler = new Mock<IPostWriteHandler>();
var mockLogger = new Mock<ILogger<PosixByondInstaller>>();
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<ArgumentNullException>(() => installer.InstallByond(null, null, default)).ConfigureAwait(false);
@@ -23,13 +23,18 @@ namespace Tgstation.Server.Host.Core.Tests
[TestMethod]
public void TestMethodThrows()
{
Assert.ThrowsException<ArgumentNullException>(() => new Application(null, null));
Assert.ThrowsException<ArgumentNullException>(() => new Application(null, null, null));
var mockConfiguration = new Mock<IConfiguration>();
Assert.ThrowsException<ArgumentNullException>(() => new Application(mockConfiguration.Object, null));
Assert.ThrowsException<ArgumentNullException>(() => new Application(mockConfiguration.Object, null, null));
var mockAssemblyInfo = new Mock<IAssemblyInformationProvider>();
mockAssemblyInfo.SetupGet(x => x.Name).Returns(typeof(Application).Assembly.GetName());
Assert.ThrowsException<ArgumentNullException>(() => new Application(mockConfiguration.Object, mockAssemblyInfo.Object, null));
var mockHostingEnvironment = new Mock<IHostingEnvironment>();
var app = new Application(mockConfiguration.Object, mockHostingEnvironment.Object);
var app = new Application(mockConfiguration.Object, mockAssemblyInfo.Object, mockHostingEnvironment.Object);
Assert.ThrowsException<ArgumentNullException>(() => app.ConfigureServices(null));
Assert.ThrowsException<ArgumentNullException>(() => app.Configure(null, null, null, null, null));
@@ -69,11 +74,11 @@ namespace Tgstation.Server.Host.Core.Tests
public void TestConfigureServicesThrowsWhenSetupWizardConfigurationDemands()
{
var mockConfiguration = new Mock<IConfiguration>();
Assert.ThrowsException<ArgumentNullException>(() => new Application(mockConfiguration.Object, null));
var mockAssemblyInfo = new Mock<IAssemblyInformationProvider>();
mockAssemblyInfo.SetupGet(x => x.Name).Returns(typeof(Application).Assembly.GetName());
var mockHostingEnvironment = new Mock<IHostingEnvironment>();
var app = new Application(mockConfiguration.Object, mockHostingEnvironment.Object);
var app = new Application(mockConfiguration.Object, mockAssemblyInfo.Object, mockHostingEnvironment.Object);
var mockOptions = new Mock<IOptions<GeneralConfiguration>>();
mockOptions.SetupGet(x => x.Value).Returns(new GeneralConfiguration
@@ -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<ArgumentNullException>(() => new ServerFactory(null, null));
IAssemblyInformationProvider assemblyInformationProvider = Mock.Of<IAssemblyInformationProvider>();
Assert.ThrowsException<ArgumentNullException>(() => new ServerFactory(assemblyInformationProvider, null));
IIOManager ioManager = Mock.Of<IIOManager>();
new ServerFactory(assemblyInformationProvider, ioManager);
}
[TestMethod]
public void TestWorksWithoutUpdatePath()
{
var factory = new ServerFactory();
var factory = ServerFactory.CreateDefault();
Assert.ThrowsException<ArgumentNullException>(() => factory.CreateServer(null, null));
factory.CreateServer(Array.Empty<string>(), 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<ArgumentNullException>(() => factory.CreateServer(null, null));
@@ -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;
+53 -3
View File
@@ -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<string>()
{
@@ -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;
}
}
}