diff --git a/docs/API.dox b/docs/API.dox index a74f211420..8c7e273186 100644 --- a/docs/API.dox +++ b/docs/API.dox @@ -9,7 +9,7 @@ The TGS4 API is designed to be a fully realized RESTful service. Once hosted, fo Routes and their usages are defined as follows -`[I (If Instance is required)] "" [Request Model] => ` +[I (If Instance is required)] <`Http Method`> "<`Route`>" [Request Model] => <`Response Model`> @section api_lib Official Libraries @@ -29,7 +29,7 @@ This document will reference the canonical C# models in the @ref Tgstation.Serve @section api_header Headers -TGS4 expects this set of headers. Failure to provide them may result in +TGS4 expects this set of headers. Failure to provide them may result in 400 error responses - User-Agent: The user agent product header value of the calling program. Should be in the form Agent/Version (i.e. SomeTgsClient/1.2.4) - Accept: application/json @@ -261,7 +261,7 @@ To clone the repository if it doesn't yet exist use the following request: I PUT "/Repository" => @ref Tgstation.Server.Api.Models.Repository -The clone job will be represented by the @ref Tgstation.Server.Api.Models.Repository.ActiveJob field. Specify the @ref Tgstation.Server.Api.Models.Repository.Origin URL. Optionally specify the initial @ref Tgstation.Server.Api.Models.Repository.Reference as a git tag or branch. Be sure to specify the authentication fields if necessary to access your repository +The clone job will be represented by the @ref Tgstation.Server.Api.Models.Repository.ActiveJob field. Specify the @ref Tgstation.Server.Api.Models.Repository.Origin URL. Optionally specify the initial @ref Tgstation.Server.Api.Models.Repository.Reference as a git tag or branch. Be sure to specify the authentication fields if necessary to access your repository. Will return 409 if the repository already exists or is already being cloned To delete an existing repository make the following request: @@ -284,14 +284,14 @@ git pull (Only if @ref Tgstation.Server.Api.Models.Repository.Reference is set): } @endcode -git checkout (Unsets @ref Tgstation.Server.Api.Models.Repository.Reference): +git checkout <`commit sha`> (Unsets @ref Tgstation.Server.Api.Models.Repository.Reference): @code{.json} { "checkoutSha": "" } @endcode -git checkout -f && git clean -fxd (Sets @ref Tgstation.Server.Api.Models.Repository.Reference): +git checkout -f <`branch or tag`> && git clean -fxd (Sets @ref Tgstation.Server.Api.Models.Repository.Reference): @code{.json} { "reference": "" @@ -364,4 +364,14 @@ The job object returned represents the compile job {} @endcode +The compiler endpoint is also used to read compile jobs + +To list successful compile jobs ids use: + +I GET "/DreamMaker/List" => Array of @ref Tgstation.Server.Api.Models.CompileJob + +To get a specific compile job use: + +I GET "/DreamMaker/{CompileJobId}" => @ref Tgstation.Server.Api.Models.CompileJob + */ diff --git a/src/Tgstation.Server.Api/ApiHeaders.cs b/src/Tgstation.Server.Api/ApiHeaders.cs index 477ba75149..8208b922c8 100644 --- a/src/Tgstation.Server.Api/ApiHeaders.cs +++ b/src/Tgstation.Server.Api/ApiHeaders.cs @@ -48,7 +48,7 @@ namespace Tgstation.Server.Api /// /// The current /// - static readonly AssemblyName assemblyName = Assembly.GetExecutingAssembly().GetName(); + internal static readonly AssemblyName assemblyName = Assembly.GetExecutingAssembly().GetName(); /// /// The being accessed @@ -85,6 +85,17 @@ namespace Tgstation.Server.Api /// public bool IsTokenAuthentication => Token != null; + /// + /// Checks if a given is compatible with our own + /// + /// The to test + /// if the given version is compatible with the API. otherwise + public static bool CheckCompatibility(Version otherVersion) + { + var ourVersion = assemblyName.Version; + return !(ourVersion.Major != otherVersion.Major || ourVersion.Minor != otherVersion.Minor || ourVersion.Build > otherVersion.Build); + } + /// /// Construct for JWT authentication /// @@ -141,10 +152,8 @@ namespace Tgstation.Server.Api ApiVersion = apiVersion; UserAgent = clientUserAgent.Product; - //check api version compatibility - var ourVersion = assemblyName.Version; - if (ourVersion.Major != ApiVersion.Major || ourVersion.Minor != ApiVersion.Minor || ourVersion.Build > ApiVersion.Build) - throw new InvalidOperationException(String.Format(CultureInfo.InvariantCulture, "Given API version is incompatible with version {0}!", ourVersion)); + if(!CheckCompatibility(ApiVersion)) + throw new InvalidOperationException(String.Format(CultureInfo.InvariantCulture, "Given API version is incompatible with version {0}!", ApiVersion)); if (!requestHeaders.Headers.TryGetValue(HeaderNames.Authorization, out StringValues authorization)) throw new InvalidOperationException(String.Format(CultureInfo.InvariantCulture, "Missing {0} header!", HeaderNames.Authorization)); @@ -207,10 +216,13 @@ namespace Tgstation.Server.Api /// Set using the . This initially clears /// /// The to set - public void SetRequestHeaders(HttpRequestHeaders headers) + /// The for the request + public void SetRequestHeaders(HttpRequestHeaders headers, long? instanceId = null) { if (headers == null) throw new ArgumentNullException(nameof(headers)); + if (instanceId.HasValue && InstanceId.HasValue && instanceId != InstanceId) + throw new InvalidOperationException("Specified instance ID in constructor and SetRequestHeaders!"); headers.Clear(); headers.Accept.Add(new MediaTypeWithQualityHeaderValue(ApplicationJson)); @@ -223,8 +235,9 @@ namespace Tgstation.Server.Api } headers.UserAgent.Add(new ProductInfoHeaderValue(UserAgent)); headers.Add(ApiVersionHeader, ApiVersion.ToString()); - if(InstanceId.HasValue) - headers.Add(instanceIdHeader, InstanceId.ToString()); + instanceId = instanceId ?? InstanceId; + if (instanceId.HasValue) + headers.Add(instanceIdHeader, instanceId.ToString()); } } } diff --git a/src/Tgstation.Server.Api/Models/ChatSettings.cs b/src/Tgstation.Server.Api/Models/ChatBot.cs similarity index 84% rename from src/Tgstation.Server.Api/Models/ChatSettings.cs rename to src/Tgstation.Server.Api/Models/ChatBot.cs index f613a4484a..7c1ee41dd0 100644 --- a/src/Tgstation.Server.Api/Models/ChatSettings.cs +++ b/src/Tgstation.Server.Api/Models/ChatBot.cs @@ -6,16 +6,16 @@ using Tgstation.Server.Api.Rights; namespace Tgstation.Server.Api.Models { /// - public sealed class ChatSettings : Internal.ChatSettings + public sealed class ChatBot : Internal.ChatBot { /// /// Channels the Discord bot should listen/announce in /// - [Permissions(WriteRight = ChatSettingsRights.WriteChannels)] + [Permissions(WriteRight = ChatBotRights.WriteChannels)] public List Channels { get; set; } /// - /// Validates are correct for the + /// Validates are correct for the /// /// public bool ValidateProviderChannelTypes() diff --git a/src/Tgstation.Server.Api/Models/DreamMaker.cs b/src/Tgstation.Server.Api/Models/DreamMaker.cs index de1d893846..f50fb7ac64 100644 --- a/src/Tgstation.Server.Api/Models/DreamMaker.cs +++ b/src/Tgstation.Server.Api/Models/DreamMaker.cs @@ -1,5 +1,4 @@ using Tgstation.Server.Api.Models.Internal; -using Tgstation.Server.Api.Rights; namespace Tgstation.Server.Api.Models { diff --git a/src/Tgstation.Server.Api/Models/ErrorMessage.cs b/src/Tgstation.Server.Api/Models/ErrorMessage.cs index 1be77b7090..05f5b03d86 100644 --- a/src/Tgstation.Server.Api/Models/ErrorMessage.cs +++ b/src/Tgstation.Server.Api/Models/ErrorMessage.cs @@ -1,4 +1,6 @@ -namespace Tgstation.Server.Api.Models +using System; + +namespace Tgstation.Server.Api.Models { /// /// Represents an error message returned by the server @@ -9,5 +11,10 @@ /// A human readable description of the error /// public string Message { get; set; } + + /// + /// The version of the API the server is using + /// + public Version SeverApiVersion { get; set; } = ApiHeaders.assemblyName.Version; } } diff --git a/src/Tgstation.Server.Api/Models/InstanceUser.cs b/src/Tgstation.Server.Api/Models/InstanceUser.cs index 68bd323b81..14727e9812 100644 --- a/src/Tgstation.Server.Api/Models/InstanceUser.cs +++ b/src/Tgstation.Server.Api/Models/InstanceUser.cs @@ -47,10 +47,10 @@ namespace Tgstation.Server.Api.Models public RepositoryRights? RepositoryRights { get; set; } /// - /// The of the + /// The of the /// [Required] - public ChatSettingsRights? ChatSettingsRights { get; set; } + public ChatBotRights? ChatBotRights { get; set; } /// /// The of the diff --git a/src/Tgstation.Server.Api/Models/Internal/ChatSettings.cs b/src/Tgstation.Server.Api/Models/Internal/ChatBot.cs similarity index 64% rename from src/Tgstation.Server.Api/Models/Internal/ChatSettings.cs rename to src/Tgstation.Server.Api/Models/Internal/ChatBot.cs index 2a23052ad8..87e7083b64 100644 --- a/src/Tgstation.Server.Api/Models/Internal/ChatSettings.cs +++ b/src/Tgstation.Server.Api/Models/Internal/ChatBot.cs @@ -6,8 +6,8 @@ namespace Tgstation.Server.Api.Models.Internal /// /// Manage the server chat bots /// - [Model(RightsType.ChatSettings, RequiresInstance = true, CanList = true, CanCrud = true, ReadRight = ChatSettingsRights.Read)] - public class ChatSettings + [Model(RightsType.ChatBots, RequiresInstance = true, CanList = true, CanCrud = true, ReadRight = ChatBotRights.Read)] + public class ChatBot { /// /// The settings id @@ -18,26 +18,26 @@ namespace Tgstation.Server.Api.Models.Internal /// /// The name of the connection /// - [Permissions(WriteRight = ChatSettingsRights.WriteName)] + [Permissions(WriteRight = ChatBotRights.WriteName)] [Required] public string Name { get; set; } /// /// If the connection is enabled /// - [Permissions(WriteRight = ChatSettingsRights.WriteEnabled)] + [Permissions(WriteRight = ChatBotRights.WriteEnabled)] public bool? Enabled { get; set; } /// /// The used for the connection /// - [Permissions(WriteRight = ChatSettingsRights.WriteProvider)] + [Permissions(WriteRight = ChatBotRights.WriteProvider)] public ChatProvider? Provider { get; set; } /// /// The information used to connect to the /// - [Permissions(ReadRight = ChatSettingsRights.ReadConnectionString, WriteRight = ChatSettingsRights.ReadConnectionString)] + [Permissions(ReadRight = ChatBotRights.ReadConnectionString, WriteRight = ChatBotRights.ReadConnectionString)] [Required] public string ConnectionString { get; set; } } diff --git a/src/Tgstation.Server.Api/Models/Internal/DreamMakerSettings.cs b/src/Tgstation.Server.Api/Models/Internal/DreamMakerSettings.cs index 154167ec82..86ad593915 100644 --- a/src/Tgstation.Server.Api/Models/Internal/DreamMakerSettings.cs +++ b/src/Tgstation.Server.Api/Models/Internal/DreamMakerSettings.cs @@ -1,4 +1,5 @@ -using Tgstation.Server.Api.Rights; +using System.ComponentModel.DataAnnotations; +using Tgstation.Server.Api.Rights; namespace Tgstation.Server.Api.Models.Internal { @@ -13,5 +14,12 @@ namespace Tgstation.Server.Api.Models.Internal /// [Permissions(WriteRight = DreamMakerRights.SetDme)] public string ProjectName { get; set; } + + /// + /// The port used during compilation to validate the TGS API + /// + [Permissions(WriteRight = DreamMakerRights.SetApiValidationPort)] + [Required] + public ushort? ApiValidationPort { get; set; } } } diff --git a/src/Tgstation.Server.Api/Models/Internal/Job.cs b/src/Tgstation.Server.Api/Models/Internal/Job.cs index b4247221e1..42c6b071a6 100644 --- a/src/Tgstation.Server.Api/Models/Internal/Job.cs +++ b/src/Tgstation.Server.Api/Models/Internal/Job.cs @@ -59,6 +59,6 @@ namespace Tgstation.Server.Api.Models.Internal /// The required to cancel the /// [Permissions(DenyWrite = true)] - public int? CancelRight { get; set; } + public ulong? CancelRight { get; set; } } } \ No newline at end of file diff --git a/src/Tgstation.Server.Api/Models/Token.cs b/src/Tgstation.Server.Api/Models/Token.cs index 9ec9a5edd5..ffbf3a16ef 100644 --- a/src/Tgstation.Server.Api/Models/Token.cs +++ b/src/Tgstation.Server.Api/Models/Token.cs @@ -1,4 +1,6 @@ -namespace Tgstation.Server.Api.Models +using System; + +namespace Tgstation.Server.Api.Models { /// /// Represents a JWT returned by the API @@ -9,5 +11,10 @@ /// The value of the JWT /// public string Bearer { get; set; } + + /// + /// When the expires + /// + public DateTimeOffset? ExpiresAt { get; set; } } } diff --git a/src/Tgstation.Server.Api/Rights/AdministrationRights.cs b/src/Tgstation.Server.Api/Rights/AdministrationRights.cs index 30a6875137..dc8c6f9dfa 100644 --- a/src/Tgstation.Server.Api/Rights/AdministrationRights.cs +++ b/src/Tgstation.Server.Api/Rights/AdministrationRights.cs @@ -6,7 +6,7 @@ namespace Tgstation.Server.Api.Rights /// Rights for /// [Flags] - public enum AdministrationRights + public enum AdministrationRights : ulong { /// /// User has no rights diff --git a/src/Tgstation.Server.Api/Rights/ByondRights.cs b/src/Tgstation.Server.Api/Rights/ByondRights.cs index ed08ed282a..4cf795d352 100644 --- a/src/Tgstation.Server.Api/Rights/ByondRights.cs +++ b/src/Tgstation.Server.Api/Rights/ByondRights.cs @@ -6,7 +6,7 @@ namespace Tgstation.Server.Api.Rights /// Rights for /// [Flags] - public enum ByondRights + public enum ByondRights : ulong { /// /// User has no rights diff --git a/src/Tgstation.Server.Api/Rights/ChatSettingsRights.cs b/src/Tgstation.Server.Api/Rights/ChatBotRights.cs similarity index 50% rename from src/Tgstation.Server.Api/Rights/ChatSettingsRights.cs rename to src/Tgstation.Server.Api/Rights/ChatBotRights.cs index a071e2bf38..d9a8c61ff1 100644 --- a/src/Tgstation.Server.Api/Rights/ChatSettingsRights.cs +++ b/src/Tgstation.Server.Api/Rights/ChatBotRights.cs @@ -3,49 +3,49 @@ namespace Tgstation.Server.Api.Rights { /// - /// Rights for + /// Rights for /// [Flags] - public enum ChatSettingsRights + public enum ChatBotRights : ulong { /// /// User has no rights /// None = 0, /// - /// User can change + /// User can change /// WriteEnabled = 1, /// - /// User can change + /// User can change /// WriteProvider = 2, /// - /// User can change + /// User can change /// WriteChannels = 4, /// - /// User can change + /// User can change /// WriteConnectionString = 8, /// - /// User can read requires + /// User can read requires /// ReadConnectionString = 16, /// - /// User can read all chat settings except + /// User can read all chat settings except /// Read = 32, /// - /// User can change + /// User can change /// WriteName = 32, /// - /// User can create new + /// User can create new /// Create = 64, /// - /// User can delete + /// User can delete /// Delete = 128 } diff --git a/src/Tgstation.Server.Api/Rights/ConfigurationRights.cs b/src/Tgstation.Server.Api/Rights/ConfigurationRights.cs index 3008353ffa..3efc5dae0f 100644 --- a/src/Tgstation.Server.Api/Rights/ConfigurationRights.cs +++ b/src/Tgstation.Server.Api/Rights/ConfigurationRights.cs @@ -6,7 +6,7 @@ namespace Tgstation.Server.Api.Rights /// Rights for /// [Flags] - public enum ConfigurationRights + public enum ConfigurationRights : ulong { /// /// User has no rights diff --git a/src/Tgstation.Server.Api/Rights/DreamDaemonRights.cs b/src/Tgstation.Server.Api/Rights/DreamDaemonRights.cs index 86665af07b..b9a950b3f0 100644 --- a/src/Tgstation.Server.Api/Rights/DreamDaemonRights.cs +++ b/src/Tgstation.Server.Api/Rights/DreamDaemonRights.cs @@ -6,7 +6,7 @@ namespace Tgstation.Server.Api.Rights /// Rights for /// [Flags] - public enum DreamDaemonRights + public enum DreamDaemonRights : ulong { /// /// User has no rights diff --git a/src/Tgstation.Server.Api/Rights/DreamMakerRights.cs b/src/Tgstation.Server.Api/Rights/DreamMakerRights.cs index bce942c165..884a42a475 100644 --- a/src/Tgstation.Server.Api/Rights/DreamMakerRights.cs +++ b/src/Tgstation.Server.Api/Rights/DreamMakerRights.cs @@ -6,7 +6,7 @@ namespace Tgstation.Server.Api.Rights /// Rights for /// [Flags] - public enum DreamMakerRights + public enum DreamMakerRights : ulong { /// /// User has no rights @@ -27,6 +27,14 @@ namespace Tgstation.Server.Api.Rights /// /// User may modify /// - SetDme = 8 + SetDme = 8, + /// + /// User may modify + /// + SetApiValidationPort = 16, + /// + /// User may list and read all s + /// + List = 32 } } diff --git a/src/Tgstation.Server.Api/Rights/InstanceManagerRights.cs b/src/Tgstation.Server.Api/Rights/InstanceManagerRights.cs index f8997edce3..c424d4ac95 100644 --- a/src/Tgstation.Server.Api/Rights/InstanceManagerRights.cs +++ b/src/Tgstation.Server.Api/Rights/InstanceManagerRights.cs @@ -6,7 +6,7 @@ namespace Tgstation.Server.Api.Rights /// Rights for managing s /// [Flags] - public enum InstanceManagerRights + public enum InstanceManagerRights : ulong { /// /// User has no rights diff --git a/src/Tgstation.Server.Api/Rights/InstanceUserRights.cs b/src/Tgstation.Server.Api/Rights/InstanceUserRights.cs index 25b85acfbd..8016731376 100644 --- a/src/Tgstation.Server.Api/Rights/InstanceUserRights.cs +++ b/src/Tgstation.Server.Api/Rights/InstanceUserRights.cs @@ -6,7 +6,7 @@ namespace Tgstation.Server.Api.Rights /// Rights for an /// [Flags] - public enum InstanceUserRights + public enum InstanceUserRights : ulong { /// /// User has no rights diff --git a/src/Tgstation.Server.Api/Rights/RepositoryRights.cs b/src/Tgstation.Server.Api/Rights/RepositoryRights.cs index 6c86be2419..83968e7adb 100644 --- a/src/Tgstation.Server.Api/Rights/RepositoryRights.cs +++ b/src/Tgstation.Server.Api/Rights/RepositoryRights.cs @@ -6,7 +6,7 @@ namespace Tgstation.Server.Api.Rights /// Rights for a /// [Flags] - public enum RepositoryRights + public enum RepositoryRights : ulong { /// /// User has no rights diff --git a/src/Tgstation.Server.Api/Rights/RightsHelper.cs b/src/Tgstation.Server.Api/Rights/RightsHelper.cs index cbd83a7ca5..7c4a9cfa5a 100644 --- a/src/Tgstation.Server.Api/Rights/RightsHelper.cs +++ b/src/Tgstation.Server.Api/Rights/RightsHelper.cs @@ -21,7 +21,7 @@ namespace Tgstation.Server.Api.Rights { RightsType.Byond, typeof(ByondRights) }, { RightsType.DreamMaker, typeof(DreamMakerRights) }, { RightsType.DreamDaemon, typeof(DreamDaemonRights) }, - { RightsType.ChatSettings, typeof(ChatSettingsRights) }, + { RightsType.ChatBots, typeof(ChatBotRights) }, { RightsType.Configuration, typeof(ConfigurationRights) }, { RightsType.InstanceUser, typeof(InstanceUserRights) } }; diff --git a/src/Tgstation.Server.Api/Rights/RightsType.cs b/src/Tgstation.Server.Api/Rights/RightsType.cs index 7ac7491661..5ebd1948e3 100644 --- a/src/Tgstation.Server.Api/Rights/RightsType.cs +++ b/src/Tgstation.Server.Api/Rights/RightsType.cs @@ -3,7 +3,7 @@ /// /// The type of rights a model uses /// - public enum RightsType + public enum RightsType : ulong { /// /// @@ -30,9 +30,9 @@ /// DreamDaemon, /// - /// + /// /// - ChatSettings, + ChatBots, /// /// /// diff --git a/src/Tgstation.Server.Api/Route.cs b/src/Tgstation.Server.Api/Route.cs deleted file mode 100644 index 99789e5443..0000000000 --- a/src/Tgstation.Server.Api/Route.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System; -using System.Net.Http; - -namespace Tgstation.Server.Api -{ - /// - /// Represents a route to a server action - /// - public sealed class Route - { - /// - /// The path to the action - /// - public string Path { get; set; } - - /// - /// The method of the action - /// - public HttpMethod Method { get; set; } - - /// - /// Adds a portion to - /// - /// The host address - /// A combined and - public Uri Flatten(Uri host) => new Uri(String.Concat(host.ToString().TrimEnd('/'), Path)); - } -} diff --git a/src/Tgstation.Server.Api/RouteHelper.cs b/src/Tgstation.Server.Api/RouteHelper.cs deleted file mode 100644 index cfe67e00cd..0000000000 --- a/src/Tgstation.Server.Api/RouteHelper.cs +++ /dev/null @@ -1,163 +0,0 @@ -using System; -using System.Linq; -using System.Net.Http; -using Tgstation.Server.Api.Models; - -namespace Tgstation.Server.Api -{ - /// - /// Gets routes for a given model - /// - public static class RouteHelper - { - /// - /// Read a - /// - /// The model type to read - /// The optional ID to pass in - /// A route to the read action - static Route Read(long? objectId) where TModel : class - { - var result = new Route { Path = String.Concat('/', typeof(TModel).Name), Method = HttpMethod.Get }; - if (objectId.HasValue) - result.Path = String.Concat(result.Path, '/', objectId); - return result; - } - - /// - /// Get the to the read action for a given - /// - /// The model to read - /// to read from if required - /// A to the read action - public static Route Read(Instance instance) where TModel : class - { - var model = (ModelAttribute)typeof(TModel).GetCustomAttributes(typeof(ModelAttribute), false).FirstOrDefault(); - if (model == default(ModelAttribute)) - throw new InvalidOperationException("TModel must have the ModelAttribute"); - if (model.RequiresInstance ^ instance != null) - throw (model.RequiresInstance ? new ArgumentNullException(nameof(instance)) : new ArgumentException("Instance is not used for this route!", nameof(instance))); - return Read(instance?.Id); - } - - /// - /// Get the to the update action for a given - /// - /// The model to update - /// A to the update action - public static Route Update(Instance instance) where TModel : class - { - var result = Read(instance); - result.Method = HttpMethod.Post; - return result; - } - - /// - /// Get the to the list action for a given - /// - /// The model to list - /// A to the list action - public static Route List(Instance instance) where TModel : class - { - var result = Read(instance); - result.Path = String.Concat(result.Path, '/', nameof(List)); - return result; - } - - /// - /// Get the to the create action for a given - /// - /// The model to create - /// A to the create action - public static Route Create(Instance instance) where TModel : class - { - var result = Read(instance); - result.Method = HttpMethod.Put; - return result; - } - - /// - /// Get the to the delete action for a given - /// - /// The model to delete - /// A to the delete action - public static Route Delete(Instance instance) where TModel : class - { - var result = Read(instance); - result.Method = HttpMethod.Delete; - return result; - } - - /// - /// Get the to a given - /// - /// The to get - /// A to the get action - public static Route GetJob(Job job) => Read(job.Id); - - /// - /// Get the to a given 's token list - /// - /// The to list tokens for - /// A to the list action - public static Route ListUserTokens(User user) - { - var result = List(null); - result.Path = String.Concat(result.Path, '/', user.Id); - return result; - } - - /// - /// Get the to a server's version - /// - /// - public static Route ServerVersion() => new Route { Path = "/", Method = HttpMethod.Get }; - - /// - /// Get the to read a file - /// - /// The the file resides in - /// The path to the file in the directory - /// A to the read action - public static Route ReadFile(Instance instance, string path) => new Route { Path = String.Concat("/Configuration/", instance?.Id ?? throw new ArgumentNullException(nameof(instance)), '/', path?.TrimStart('/') ?? throw new ArgumentNullException(nameof(path))), Method = HttpMethod.Get }; - - /// - /// Get the to list files for a - /// - /// The the file resides in - /// The directory to list - /// A to the read action - public static Route ListFiles(Instance instance, string directory) - { - var result = ReadFile(instance, directory); - result.Path = String.Concat("/ConfigurationList", result.Path.Substring(result.Path.IndexOf('/', 1))); - return result; - } - - /// - /// Get the to create a file - /// - /// The the file resides in - /// The path to the file in the directory - /// A to the create action - public static Route CreateFile(Instance instance, string path) - { - var result = ReadFile(instance, path); - result.Method = HttpMethod.Put; - return result; - } - - /// - /// Get the to delete a file - /// - /// The the file resides in - /// The path to the file in the directory - /// A to the delete action - public static Route DeleteFile(Instance instance, string path) - { - var result = ReadFile(instance, path); - result.Method = HttpMethod.Delete; - return result; - } - } -} diff --git a/src/Tgstation.Server.Api/Routes.cs b/src/Tgstation.Server.Api/Routes.cs new file mode 100644 index 0000000000..da1bfc9bd9 --- /dev/null +++ b/src/Tgstation.Server.Api/Routes.cs @@ -0,0 +1,86 @@ +using System; +using System.Globalization; + +namespace Tgstation.Server.Api +{ + /// + /// Routes to a server actions + /// + public static class Routes + { + /// + /// The root controller + /// + public const string Root = "/"; + + /// + /// The controller + /// + public const string Administration = Root + nameof(Models.Administration); + + /// + /// The controller + /// + public const string User = Root + nameof(Models.User); + + /// + /// The controller + /// + public const string InstanceManager = Root + nameof(Models.Instance); + + /// + /// The controller + /// + public const string Byond = Root + nameof(Models.Byond); + + /// + /// The controller + /// + public const string Repository = Root + nameof(Models.Repository); + + /// + /// The controller + /// + public const string DreamDaemon = Root + nameof(Models.DreamDaemon); + + /// + /// The controller + /// + public const string Configuration = Root + "Config"; + + /// + /// The controller + /// + public const string InstanceUser = Root + nameof(Models.InstanceUser); + + /// + /// The controller + /// + public const string Chat = Root + "Chat"; + + /// + /// The controller + /// + public const string DreamMaker = Root + nameof(Models.DreamMaker); + + /// + /// The controller + /// + public const string Jobs = Root + nameof(Models.Job); + + /// + /// Apply an postfix to a + /// + /// The route + /// The ID + /// The with appended + public static string SetID(string route, long id) => String.Format(CultureInfo.InvariantCulture, "{0}/{1}", route, id); + + /// + /// Get the /List postfix for a + /// + /// The route + /// The with /List appended + public static string List(string route) => String.Format(CultureInfo.InvariantCulture, "{0}/List", route); + } +} diff --git a/src/Tgstation.Server.Api/Routes/Administration.cs b/src/Tgstation.Server.Api/Routes/Administration.cs deleted file mode 100644 index e5d7b1cfcb..0000000000 --- a/src/Tgstation.Server.Api/Routes/Administration.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Tgstation.Server.Api.Routes -{ - /// - /// Routes for - /// - public static class Administration - { - /// - /// The base route - /// - public const string Base = "/" + nameof(Administration); - } -} diff --git a/src/Tgstation.Server.Api/Tgstation.Server.Api.csproj b/src/Tgstation.Server.Api/Tgstation.Server.Api.csproj index b963809ded..0b6aa59c86 100644 --- a/src/Tgstation.Server.Api/Tgstation.Server.Api.csproj +++ b/src/Tgstation.Server.Api/Tgstation.Server.Api.csproj @@ -25,12 +25,12 @@ true bin\Release\netstandard2.0\Tgstation.Server.Api.xml - 1701;1702;1705;CA2227;CA1819 + 1701;1702;1705;CA2227;CA1819;CA1028 latest - 1701;1702;1705;CA2227;CA1819 + 1701;1702;1705;CA2227;CA1819;CA1028 diff --git a/src/Tgstation.Server.Client/AdministrationClient.cs b/src/Tgstation.Server.Client/AdministrationClient.cs new file mode 100644 index 0000000000..3c1204ae78 --- /dev/null +++ b/src/Tgstation.Server.Client/AdministrationClient.cs @@ -0,0 +1,31 @@ +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Client +{ + /// + sealed class AdministrationClient : IAdministrationClient + { + /// + /// The for the + /// + readonly IApiClient apiClient; + + /// + /// Construct an + /// + /// The value of + public AdministrationClient(IApiClient apiClient) + { + this.apiClient = apiClient; + } + + /// + public Task Read(CancellationToken cancellationToken) => apiClient.Read(Routes.Administration, cancellationToken); + + /// + public Task Update(Administration administration, CancellationToken cancellationToken) => apiClient.Update(Routes.Administration, administration, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Tgstation.Server.Client/ApiClient.cs b/src/Tgstation.Server.Client/ApiClient.cs new file mode 100644 index 0000000000..e2d7aab334 --- /dev/null +++ b/src/Tgstation.Server.Client/ApiClient.cs @@ -0,0 +1,177 @@ +using Newtonsoft.Json; +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Client +{ + /// + sealed class ApiClient : IApiClient + { + /// + public Uri Url { get; } + + /// + public ApiHeaders Headers { get; } + + /// + public TimeSpan Timeout + { + get => httpClient.Timeout; + set => httpClient.Timeout = value; + } + + /// + /// The for the + /// + readonly HttpClient httpClient; + + /// + /// Construct an + /// + /// The value of + /// The value of + public ApiClient(Uri url, ApiHeaders apiHeaders) + { + Url = url ?? throw new ArgumentNullException(nameof(url)); + Headers = apiHeaders ?? throw new ArgumentNullException(nameof(apiHeaders)); + + httpClient = new HttpClient(); + } + + /// + public void Dispose() => httpClient.Dispose(); + + /// + /// Main request method + /// + /// The route to run + /// The body of the request + /// The method of the request + /// The optional for the request + /// The for the operation + /// A resulting in the response on success + async Task RunRequest(string route, object body, HttpMethod method, long? instanceId, CancellationToken cancellationToken) + { + if (route == null) + throw new ArgumentNullException(nameof(route)); + if (method == null) + throw new ArgumentNullException(nameof(method)); + if (body == null && (method == HttpMethod.Post || method == HttpMethod.Put)) + throw new InvalidOperationException("Body cannot be null for POST or PUT!"); + + var fullUri = new Uri(Url, route); + + HttpContent content = null; + if (body != null) + content = new StringContent(JsonConvert.SerializeObject(body)); + + Task task; + lock (this) + { + httpClient.DefaultRequestHeaders.Clear(); + Headers.SetRequestHeaders(httpClient.DefaultRequestHeaders, instanceId); + + if (method == HttpMethod.Get) + task = httpClient.GetAsync(route); + else if (method == HttpMethod.Put) + task = httpClient.PutAsync(fullUri, content, cancellationToken); + else if (method == HttpMethod.Post) + task = httpClient.PostAsync(fullUri, content, cancellationToken); + else if (method == HttpMethod.Delete) + task = httpClient.DeleteAsync(fullUri, cancellationToken); + else + throw new NotSupportedException(); + } + + var response = await task.ConfigureAwait(false); + + var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) { + ErrorMessage errorMessage = null; + try + { + //check if json serializes to an error message + errorMessage = JsonConvert.DeserializeObject(json); + } + catch (JsonSerializationException) { } + + switch (response.StatusCode) + { + case HttpStatusCode.BadRequest: + //validate our api version is compatible + if(errorMessage != null && ApiHeaders.CheckCompatibility(errorMessage.SeverApiVersion)) + throw new ApiMismatchException(errorMessage); + goto default; + case HttpStatusCode.Unauthorized: + throw new UnauthorizedException(); + case HttpStatusCode.RequestTimeout: + throw new RequestTimeoutException(); + case HttpStatusCode.Forbidden: + throw new InsufficientPermissionsException(); + case HttpStatusCode.Gone: + case HttpStatusCode.NotFound: + case HttpStatusCode.Conflict: + throw new ConflictException(errorMessage, response.StatusCode); + case HttpStatusCode.NotImplemented: + throw new MethodNotSupportedException(); + case HttpStatusCode.InternalServerError: + //response + throw new ServerErrorException(json); //json is html + case (HttpStatusCode)429: //rate limited + response.Headers.TryGetValues("Retry-After", out var values); + throw new RateLimitException(values?.FirstOrDefault()); + default: + throw new ApiConflictException(errorMessage, response.StatusCode); + } + } + + if (String.IsNullOrWhiteSpace(json)) + json = JsonConvert.SerializeObject(new object()); + + return JsonConvert.DeserializeObject(json); + } + + /// + public Task Create(string route, CancellationToken cancellationToken) => RunRequest(route, new object(), HttpMethod.Put, null, cancellationToken); + + /// + public Task Read(string route, CancellationToken cancellationToken) => RunRequest(route, null, HttpMethod.Get, null, cancellationToken); + + /// + public Task Update(string route, CancellationToken cancellationToken) => RunRequest(route, new object(), HttpMethod.Post, null, cancellationToken); + + /// + public Task Update(string route, TBody body, CancellationToken cancellationToken) => RunRequest(route, body, HttpMethod.Post, null, cancellationToken); + + /// + public Task Update(string route, TBody body, CancellationToken cancellationToken) => RunRequest(route, body, HttpMethod.Post, null, cancellationToken); + + /// + public Task Create(string route, TBody body, CancellationToken cancellationToken) => RunRequest(route, body, HttpMethod.Put, null, cancellationToken); + + /// + public Task Delete(string route, CancellationToken cancellationToken) => RunRequest(route, null, HttpMethod.Delete, null, cancellationToken); + + /// + public Task Create(string route, TBody body, long instanceId, CancellationToken cancellationToken) => RunRequest(route, body, HttpMethod.Put, instanceId, cancellationToken); + + /// + public Task Read(string route, long instanceId, CancellationToken cancellationToken) => RunRequest(route, null, HttpMethod.Get, instanceId, cancellationToken); + + /// + public Task Update(string route, TBody body, long instanceId, CancellationToken cancellationToken) => RunRequest(route, body, HttpMethod.Post, instanceId, cancellationToken); + + /// + public Task Delete(string route, long instanceId, CancellationToken cancellationToken) => RunRequest(route, null, HttpMethod.Delete, instanceId, cancellationToken); + + /// + public Task Create(string route, long instanceId, CancellationToken cancellationToken) => RunRequest(route, new object(), HttpMethod.Put, instanceId, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Tgstation.Server.Client/ApiClientFactory.cs b/src/Tgstation.Server.Client/ApiClientFactory.cs new file mode 100644 index 0000000000..64dae0469f --- /dev/null +++ b/src/Tgstation.Server.Client/ApiClientFactory.cs @@ -0,0 +1,12 @@ +using System; +using Tgstation.Server.Api; + +namespace Tgstation.Server.Client +{ + /// + sealed class ApiClientFactory : IApiClientFactory + { + /// + public IApiClient CreateApiClient(Uri url, ApiHeaders apiHeaders) => new ApiClient(url, apiHeaders); + } +} \ No newline at end of file diff --git a/src/Tgstation.Server.Client/ApiConflictException.cs b/src/Tgstation.Server.Client/ApiConflictException.cs new file mode 100644 index 0000000000..ba3eaa90c6 --- /dev/null +++ b/src/Tgstation.Server.Client/ApiConflictException.cs @@ -0,0 +1,37 @@ +using System; +using System.Net; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Client +{ + /// + /// Occurs when the server returns an unknown response + /// + public sealed class ApiConflictException : ClientException + { + /// + /// Construct an using an + /// + /// The for the + /// The for the + public ApiConflictException(ErrorMessage errorMessage, HttpStatusCode statusCode) : base(errorMessage, statusCode) { } + + /// + /// Construct an + /// + public ApiConflictException() { } + + /// + /// Construct an with a + /// + /// The message for the + public ApiConflictException(string message) : base(message) { } + + /// + /// Construct an with a and + /// + /// The message for the + /// The inner for the base + public ApiConflictException(string message, Exception innerException) : base(message, innerException) { } + } +} \ No newline at end of file diff --git a/src/Tgstation.Server.Client/ApiMismatchException.cs b/src/Tgstation.Server.Client/ApiMismatchException.cs new file mode 100644 index 0000000000..cf679d1f52 --- /dev/null +++ b/src/Tgstation.Server.Client/ApiMismatchException.cs @@ -0,0 +1,40 @@ +using System; +using System.Net; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Client +{ + /// + /// Occurs when the API version of the client is not compatible with the server's + /// + public sealed class ApiMismatchException : ClientException + { + /// + /// Construct an using an + /// + /// The for the + public ApiMismatchException(ErrorMessage errorMessage) : base(errorMessage, HttpStatusCode.BadRequest) + { + if (errorMessage == null) + throw new ArgumentNullException(nameof(errorMessage)); + } + + /// + /// Construct an + /// + public ApiMismatchException() { } + + /// + /// Construct an with a + /// + /// The message for the + public ApiMismatchException(string message) : base(message) { } + + /// + /// Construct an with a and + /// + /// The message for the + /// The inner for the base + public ApiMismatchException(string message, Exception innerException) : base(message, innerException) { } + } +} \ No newline at end of file diff --git a/src/Tgstation.Server.Client/ClientException.cs b/src/Tgstation.Server.Client/ClientException.cs new file mode 100644 index 0000000000..da0207e3da --- /dev/null +++ b/src/Tgstation.Server.Client/ClientException.cs @@ -0,0 +1,48 @@ +using System; +using System.Net; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Client +{ + /// + /// Exceptions thrown by s + /// + public abstract class ClientException : Exception + { + /// + /// The of the + /// + HttpStatusCode StatusCode { get; } + + Version ServerApiVersion { get; } + + /// + /// Construct a using an and + /// + /// The associated with the + /// The of the + protected ClientException(ErrorMessage errorMessage, HttpStatusCode statusCode) : base(errorMessage == null ? throw new ArgumentNullException(nameof(errorMessage)) : errorMessage.Message ?? "Unknown Error") + { + StatusCode = statusCode; + ServerApiVersion = errorMessage?.SeverApiVersion; + } + + /// + /// Construct a + /// + protected ClientException() { } + + /// + /// Construct a with a + /// + /// The message for the + protected ClientException(string message) : base(message) { } + + /// + /// Construct a with a and + /// + /// The message for the + /// The inner for the base + protected ClientException(string message, Exception innerException) : base(message, innerException) { } + } +} diff --git a/src/Tgstation.Server.Client/Components/ByondClient.cs b/src/Tgstation.Server.Client/Components/ByondClient.cs new file mode 100644 index 0000000000..ec311873c7 --- /dev/null +++ b/src/Tgstation.Server.Client/Components/ByondClient.cs @@ -0,0 +1,38 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Client.Components +{ + /// + sealed class ByondClient : IByondClient + { + /// + /// The for the + /// + readonly IApiClient apiClient; + /// + /// The for the + /// + readonly Instance instance; + + /// + /// Construct a + /// + /// The value of + /// The value of + public ByondClient(IApiClient apiClient, Instance instance) + { + this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); + this.instance = instance ?? throw new ArgumentNullException(nameof(instance)); + } + + /// + public Task Read(CancellationToken cancellationToken) => apiClient.Read(Routes.Byond, instance.Id, cancellationToken); + + /// + public Task Update(Byond byond, CancellationToken cancellationToken) => apiClient.Update(Routes.Byond, byond, instance.Id, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Tgstation.Server.Client/Components/ChatBotsClient.cs b/src/Tgstation.Server.Client/Components/ChatBotsClient.cs new file mode 100644 index 0000000000..b9fc623102 --- /dev/null +++ b/src/Tgstation.Server.Client/Components/ChatBotsClient.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Client.Components +{ + /// + sealed class ChatBotsClient : IChatBotsClient + { + /// + /// The for the + /// + readonly IApiClient apiClient; + /// + /// The for the + /// + readonly Instance instance; + + /// + /// Construct a + /// + /// The value of + /// The value of + public ChatBotsClient(IApiClient apiClient, Instance instance) + { + this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); + this.instance = instance ?? throw new ArgumentNullException(nameof(instance)); + } + + /// + public Task Create(ChatBot settings, CancellationToken cancellationToken) => apiClient.Create(Routes.Chat, settings, instance.Id, cancellationToken); + + /// + public Task Delete(ChatBot settings, CancellationToken cancellationToken) => apiClient.Delete(Routes.SetID(Routes.Chat, settings.Id), instance.Id, cancellationToken); + + /// + public Task> List(CancellationToken cancellationToken) => apiClient.Create>(Routes.List(Routes.Chat), instance.Id, cancellationToken); + + /// + public Task Update(ChatBot settings, CancellationToken cancellationToken) => apiClient.Update(Routes.Chat, settings, instance.Id, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Tgstation.Server.Client/Components/ConfigurationClient.cs b/src/Tgstation.Server.Client/Components/ConfigurationClient.cs new file mode 100644 index 0000000000..6eac9582bc --- /dev/null +++ b/src/Tgstation.Server.Client/Components/ConfigurationClient.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Client.Components +{ + /// + sealed class ConfigurationClient : IConfigurationClient + { + /// + /// The for the + /// + readonly IApiClient apiClient; + /// + /// The for the + /// + readonly Instance instance; + + /// + /// Construct a + /// + /// The value of + /// The value of + public ConfigurationClient(IApiClient apiClient, Instance instance) + { + this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); + this.instance = instance ?? throw new ArgumentNullException(nameof(instance)); + } + + /// + public Task> List(string directory, CancellationToken cancellationToken) + { + if (directory == null) + directory = String.Empty; + return apiClient.Read>(Routes.List(Routes.Configuration) + directory, instance.Id, cancellationToken); + } + + /// + public Task Read(ConfigurationFile file, CancellationToken cancellationToken) + { + if (file == null) + throw new ArgumentNullException(nameof(file)); + return apiClient.Read(Routes.Configuration + file.Path, instance.Id, cancellationToken); + } + + /// + public Task Write(ConfigurationFile file, CancellationToken cancellationToken) => apiClient.Update(Routes.Configuration, file, instance.Id, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Tgstation.Server.Client/Components/DreamDaemonClient.cs b/src/Tgstation.Server.Client/Components/DreamDaemonClient.cs new file mode 100644 index 0000000000..d776c09f69 --- /dev/null +++ b/src/Tgstation.Server.Client/Components/DreamDaemonClient.cs @@ -0,0 +1,44 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Client.Components +{ + /// + sealed class DreamDaemonClient : IDreamDaemonClient + { + /// + /// The for the + /// + readonly IApiClient apiClient; + /// + /// The for the + /// + readonly Instance instance; + + /// + /// Construct a + /// + /// The value of + /// The value of + public DreamDaemonClient(IApiClient apiClient, Instance instance) + { + this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); + this.instance = instance ?? throw new ArgumentNullException(nameof(instance)); + } + + /// + public Task Shutdown(CancellationToken cancellationToken) => apiClient.Delete(Routes.DreamDaemon, instance.Id, cancellationToken); + + /// + public Task Start(CancellationToken cancellationToken) => apiClient.Create(Routes.DreamDaemon, instance.Id, cancellationToken); + + /// + public Task Read(CancellationToken cancellationToken) => apiClient.Read(Routes.DreamDaemon, instance.Id, cancellationToken); + + /// + public Task Update(DreamDaemon dreamDaemon, CancellationToken cancellationToken) => apiClient.Update(Routes.DreamDaemon, dreamDaemon, instance.Id, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Tgstation.Server.Client/Components/DreamMakerClient.cs b/src/Tgstation.Server.Client/Components/DreamMakerClient.cs new file mode 100644 index 0000000000..ae331617e2 --- /dev/null +++ b/src/Tgstation.Server.Client/Components/DreamMakerClient.cs @@ -0,0 +1,41 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Client.Components +{ + /// + sealed class DreamMakerClient : IDreamMakerClient + { + /// + /// The for the + /// + private IApiClient apiClient; + /// + /// The for the + /// + private Instance instance; + + /// + /// Construct a + /// + /// The value of + /// The value of + public DreamMakerClient(IApiClient apiClient, Instance instance) + { + this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); + this.instance = instance ?? throw new ArgumentNullException(nameof(instance)); + } + + /// + public Task Compile(CancellationToken cancellationToken) => apiClient.Create(Routes.DreamMaker, instance.Id, cancellationToken); + + /// + public Task Read(CancellationToken cancellationToken) => apiClient.Read(Routes.DreamMaker, instance.Id, cancellationToken); + + /// + public Task Update(DreamMaker dreamMaker, CancellationToken cancellationToken) => apiClient.Update(Routes.DreamMaker, dreamMaker, instance.Id, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Tgstation.Server.Client/Components/IByondClient.cs b/src/Tgstation.Server.Client/Components/IByondClient.cs index 9f9d8421c6..bfe9e8f003 100644 --- a/src/Tgstation.Server.Client/Components/IByondClient.cs +++ b/src/Tgstation.Server.Client/Components/IByondClient.cs @@ -1,29 +1,27 @@ -using System; -using System.Threading; +using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api.Models; -using Tgstation.Server.Api.Rights; namespace Tgstation.Server.Client.Components { /// /// For managing the installation /// - public interface IByondClient : IRightsClient + public interface IByondClient { /// - /// Get the represented by the + /// Get the information /// /// The for the operation - /// A resulting in the represented by the + /// A resulting in the information Task Read(CancellationToken cancellationToken); /// - /// Updates the installed BYOND + /// Updates the information /// - /// The to set to active + /// The information to update /// The for the operation - /// A representing the running operation - Task SetActiveVersion(Version version, CancellationToken cancellationToken); + /// A resulting in the updated information + Task Update(Byond byond, CancellationToken cancellationToken); } } diff --git a/src/Tgstation.Server.Client/Components/IChatBotsClient.cs b/src/Tgstation.Server.Client/Components/IChatBotsClient.cs new file mode 100644 index 0000000000..538fdf72bc --- /dev/null +++ b/src/Tgstation.Server.Client/Components/IChatBotsClient.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Client.Components +{ + /// + /// For managing the chat bots + /// + public interface IChatBotsClient + { + /// + /// List the + /// + /// The for the operation + /// A resulting in a of the of the server + Task> List(CancellationToken cancellationToken); + + /// + /// Create a + /// + /// The to create + /// The for the operation + /// A resulting in the new + Task Create(ChatBot settings, CancellationToken cancellationToken); + + /// + /// Updates a setttings + /// + /// The to update + /// The for the operation + /// A resulting in the updated + Task Update(ChatBot settings, CancellationToken cancellationToken); + + /// + /// Delete a + /// + /// The to delete + /// The for the operation + /// A representing the running operation + Task Delete(ChatBot settings, CancellationToken cancellationToken); + } +} diff --git a/src/Tgstation.Server.Client/Components/IChatSettingsClient.cs b/src/Tgstation.Server.Client/Components/IChatSettingsClient.cs deleted file mode 100644 index d8ad100b18..0000000000 --- a/src/Tgstation.Server.Client/Components/IChatSettingsClient.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; -using Tgstation.Server.Api.Models; -using Tgstation.Server.Api.Rights; - -namespace Tgstation.Server.Client.Components -{ - /// - /// For managing the chat bots - /// - public interface IChatSettingsClient : IRightsClient - { - /// - /// Get the represented by the - /// - /// The for the operation - /// A resulting in the represented by the - Task Read(CancellationToken cancellationToken); - - /// - /// Updates the setttings - /// - /// The to update - /// The for the operation - /// A representing the running operation - Task Update(ChatSettings chat, CancellationToken cancellationToken); - } -} diff --git a/src/Tgstation.Server.Client/Components/IConfigurationClient.cs b/src/Tgstation.Server.Client/Components/IConfigurationClient.cs index af84c1ca73..950c346775 100644 --- a/src/Tgstation.Server.Client/Components/IConfigurationClient.cs +++ b/src/Tgstation.Server.Client/Components/IConfigurationClient.cs @@ -1,16 +1,14 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api.Models; -using Tgstation.Server.Api.Rights; namespace Tgstation.Server.Client.Components { /// /// For managing files /// - public interface IConfigurationClient : IRightsClient + public interface IConfigurationClient { /// /// List configuration files @@ -26,30 +24,14 @@ namespace Tgstation.Server.Client.Components /// The file to read /// The for the operation /// A representing the running operation - Task Read(ConfigurationFile file, CancellationToken cancellationToken); + Task Read(ConfigurationFile file, CancellationToken cancellationToken); /// - /// Overwrite a file with integrity checks + /// Overwrite a file /// /// The file to write /// The for the operation /// A representing the running operation - Task Write(ConfigurationFile file, CancellationToken cancellationToken); - - /// - /// Create/overwrite a file - /// - /// The file to write - /// The for the operation - /// A representing the running operation - Task Create(ConfigurationFile file, CancellationToken cancellationToken); - - /// - /// Delete a file - /// - /// The file to delete - /// The for the operation - /// A representing the running operation - Task Delete(ConfigurationFile file, CancellationToken cancellationToken); + Task Write(ConfigurationFile file, CancellationToken cancellationToken); } } diff --git a/src/Tgstation.Server.Client/Components/IDreamDaemonClient.cs b/src/Tgstation.Server.Client/Components/IDreamDaemonClient.cs index 1947c4170d..0bb8a1d72b 100644 --- a/src/Tgstation.Server.Client/Components/IDreamDaemonClient.cs +++ b/src/Tgstation.Server.Client/Components/IDreamDaemonClient.cs @@ -1,34 +1,33 @@ using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api.Models; -using Tgstation.Server.Api.Rights; namespace Tgstation.Server.Client.Components { /// /// For managing /// - public interface IDreamDaemonClient: IRightsClient + public interface IDreamDaemonClient { /// /// Get the represented by the /// /// The for the operation - /// A resulting in the represented by the + /// A resulting in the information Task Read(CancellationToken cancellationToken); /// /// Start /// /// The for the operation - /// A representing the running operation - Task Start(CancellationToken cancellationToken); + /// A resulting in the information + Task Start(CancellationToken cancellationToken); /// /// Shutdown /// /// The for the operation - /// A representing the running operation + /// A resulting in the information Task Shutdown(CancellationToken cancellationToken); /// @@ -36,7 +35,7 @@ namespace Tgstation.Server.Client.Components /// /// The to update /// The for the operation - /// A representing the running operation - Task Update(DreamDaemon dreamDaemon, CancellationToken cancellationToken); + /// A resulting in the information + Task Update(DreamDaemon dreamDaemon, CancellationToken cancellationToken); } } diff --git a/src/Tgstation.Server.Client/Components/IDreamMakerClient.cs b/src/Tgstation.Server.Client/Components/IDreamMakerClient.cs index ef1fb7b29b..fa202a8e18 100644 --- a/src/Tgstation.Server.Client/Components/IDreamMakerClient.cs +++ b/src/Tgstation.Server.Client/Components/IDreamMakerClient.cs @@ -1,21 +1,20 @@ using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api.Models; -using Tgstation.Server.Api.Rights; namespace Tgstation.Server.Client.Components { /// /// For managing the compiler /// - public interface IDreamMakerClient : IRightsClient + public interface IDreamMakerClient { /// - /// Get the represented by the + /// Get the information /// /// The for the operation - /// A resulting in the represented by the - Task Read(CancellationToken cancellationToken); + /// A resulting in the information + Task Read(CancellationToken cancellationToken); /// /// Updates the setttings @@ -23,13 +22,13 @@ namespace Tgstation.Server.Client.Components /// The to update /// The for the operation /// A representing the running operation - Task Update(DreamMaker dreamMaker, CancellationToken cancellationToken); + Task Update(DreamMaker dreamMaker, CancellationToken cancellationToken); /// /// Compile the current repository revision /// /// The for the operation - /// A representing the running operation - Task Compile(CancellationToken cancellationToken); + /// A resulting in the for the compile + Task Compile(CancellationToken cancellationToken); } } diff --git a/src/Tgstation.Server.Client/Components/IInstanceClient.cs b/src/Tgstation.Server.Client/Components/IInstanceClient.cs index 36b5b605f5..3806edff94 100644 --- a/src/Tgstation.Server.Client/Components/IInstanceClient.cs +++ b/src/Tgstation.Server.Client/Components/IInstanceClient.cs @@ -1,5 +1,4 @@ -using System.Threading; -using System.Threading.Tasks; +using System; using Tgstation.Server.Api.Models; namespace Tgstation.Server.Client.Components @@ -9,6 +8,11 @@ namespace Tgstation.Server.Client.Components /// public interface IInstanceClient { + /// + /// The used to create the + /// + Instance Metadata { get; } + /// /// Access the /// @@ -19,10 +23,10 @@ namespace Tgstation.Server.Client.Components /// IRepositoryClient Repository { get; } - /// - /// Access the - /// - IDreamDaemonClient DreamDaemon { get; } + /// + /// Access the + /// + IDreamDaemonClient DreamDaemon { get; } /// /// Access the @@ -35,20 +39,18 @@ namespace Tgstation.Server.Client.Components IInstanceUserClient Users { get; } /// - /// Access the + /// Access the /// - IChatSettingsClient Chat { get; } - + IChatBotsClient ChatBots { get; } + /// /// Access the /// IDreamMakerClient DreamMaker { get; } /// - /// Get the represented by the + /// Access the /// - /// The for the operation - /// A resulting in the represented by the - Task Read(CancellationToken cancellationToken); + IJobsClient Jobs { get; } } } \ No newline at end of file diff --git a/src/Tgstation.Server.Client/Components/IInstanceUserClient.cs b/src/Tgstation.Server.Client/Components/IInstanceUserClient.cs index 65248e42d5..9d32f8bb1a 100644 --- a/src/Tgstation.Server.Client/Components/IInstanceUserClient.cs +++ b/src/Tgstation.Server.Client/Components/IInstanceUserClient.cs @@ -2,28 +2,50 @@ using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api.Models; -using Tgstation.Server.Api.Rights; namespace Tgstation.Server.Client.Components { /// /// For managing s /// - public interface IInstanceUserClient : IRightsClient - { - /// - /// Get the s in the - /// - /// The for the operation - /// A resulting in a of s in the instance - Task> Read(CancellationToken cancellationToken); + public interface IInstanceUserClient + { + /// + /// Get the associated with the logged on user + /// + /// The for the operation + /// A resulting in the associated with the logged on user + Task Read(CancellationToken cancellationToken); - /// - /// Update a - /// - /// The to update - /// The for the operation - /// A representing the running operation - Task Update(InstanceUser instanceUser, CancellationToken cancellationToken); - } + /// + /// Get the s in the + /// + /// The for the operation + /// A resulting in a of s in the instance + Task> List(CancellationToken cancellationToken); + + /// + /// Update a + /// + /// The to update + /// The for the operation + /// A representing the running operation + Task Update(InstanceUser instanceUser, CancellationToken cancellationToken); + + /// + /// Create a + /// + /// The to create + /// The for the operation + /// A reulting in the new + Task Create(InstanceUser instanceUser, CancellationToken cancellationToken); + + /// + /// Delete a + /// + /// The to delete + /// The for the operation + /// A representing the running operation + Task Delete(InstanceUser instanceUser, CancellationToken cancellationToken); + } } \ No newline at end of file diff --git a/src/Tgstation.Server.Client/Components/IJobsClient.cs b/src/Tgstation.Server.Client/Components/IJobsClient.cs index 7f912b3ab1..151dcfc33e 100644 --- a/src/Tgstation.Server.Client/Components/IJobsClient.cs +++ b/src/Tgstation.Server.Client/Components/IJobsClient.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api.Models; @@ -11,12 +12,20 @@ namespace Tgstation.Server.Client.Components public interface IJobsClient { /// - /// List the active jobs the user can view + /// List the s in the /// /// The for the operation - /// A resulting in a of active s the user can view + /// A resulting in a of the s in the Task> List(CancellationToken cancellationToken); + /// + /// Get a + /// + /// The to get + /// The for the operation + /// A resulting in the + Task Read(Job job, CancellationToken cancellationToken); + /// /// Cancels a /// @@ -24,5 +33,15 @@ namespace Tgstation.Server.Client.Components /// The for the operation /// A representing the running operation Task Cancel(Job job, CancellationToken cancellationToken); - } + + /// + /// Creates a that completes when a given is completed + /// + /// The to create a for + /// The rate in to poll the server for results + /// A to run with 0-100 progress + /// The which will trigger the cancellation of the + /// A resulting in a complete + Task CreateTaskFromJob(Job job, TimeSpan requeryRate, Action progressCallback, CancellationToken cancellationToken); + } } diff --git a/src/Tgstation.Server.Client/Components/IRepositoryClient.cs b/src/Tgstation.Server.Client/Components/IRepositoryClient.cs index a3ee2188ad..89d6e86cdc 100644 --- a/src/Tgstation.Server.Client/Components/IRepositoryClient.cs +++ b/src/Tgstation.Server.Client/Components/IRepositoryClient.cs @@ -2,29 +2,34 @@ using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api.Models; -using Tgstation.Server.Api.Rights; namespace Tgstation.Server.Client.Components { /// /// For managing the /// - public interface IRepositoryClient : IRightsClient + public interface IRepositoryClient { /// /// Get the represented by the /// /// The for the operation - /// A resulting in the represented by the + /// A resulting in the Task Read(CancellationToken cancellationToken); /// /// Update the /// /// The to update - /// Optional action to take when progress is reported. Will either not be called for some operations, or be called with the numbers 1-100 + /// The for the operation + /// A resulting in the updated + Task Update(Repository repository, CancellationToken cancellationToken); + + /// + /// Deletes the + /// /// The for the operation /// A representing the running operation - Task Update(Repository repository, Action progressCallback, CancellationToken cancellationToken); + Task Delete(CancellationToken cancellationToken); } } diff --git a/src/Tgstation.Server.Client/Components/InstanceClient.cs b/src/Tgstation.Server.Client/Components/InstanceClient.cs new file mode 100644 index 0000000000..2bf76eca71 --- /dev/null +++ b/src/Tgstation.Server.Client/Components/InstanceClient.cs @@ -0,0 +1,61 @@ +using System; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Client.Components +{ + /// + sealed class InstanceClient : IInstanceClient + { + /// + public Instance Metadata { get; } + + /// + public IByondClient Byond { get; } + + /// + public IRepositoryClient Repository { get; } + + /// + public IDreamDaemonClient DreamDaemon { get; } + + /// + public IConfigurationClient Configuration { get; } + + /// + public IInstanceUserClient Users { get; } + + /// + public IChatBotsClient ChatBots { get; } + + /// + public IDreamMakerClient DreamMaker { get; } + + /// + public IJobsClient Jobs { get; } + + /// + /// The for the + /// + readonly IApiClient apiClient; + + /// + /// Construct a + /// + /// The value of + /// The value of + public InstanceClient(IApiClient apiClient, Instance instance) + { + this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); + Metadata = instance ?? throw new ArgumentNullException(nameof(instance)); + + Byond = new ByondClient(apiClient, instance); + Repository = new RepositoryClient(apiClient, instance); + DreamDaemon = new DreamDaemonClient(apiClient, instance); + Configuration = new ConfigurationClient(apiClient, instance); + Users = new InstanceUserClient(apiClient, instance); + ChatBots = new ChatBotsClient(apiClient, instance); + DreamMaker = new DreamMakerClient(apiClient, instance); + Jobs = new JobsClient(apiClient, instance); + } + } +} \ No newline at end of file diff --git a/src/Tgstation.Server.Client/Components/InstanceUserClient.cs b/src/Tgstation.Server.Client/Components/InstanceUserClient.cs new file mode 100644 index 0000000000..75d95a91bf --- /dev/null +++ b/src/Tgstation.Server.Client/Components/InstanceUserClient.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Client.Components +{ + /// + sealed class InstanceUserClient : IInstanceUserClient + { + /// + /// The for the + /// + readonly IApiClient apiClient; + /// + /// The for the + /// + readonly Instance instance; + + /// + /// Construct an + /// + /// The value of + /// The value of + public InstanceUserClient(IApiClient apiClient, Instance instance) + { + this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); + this.instance = instance ?? throw new ArgumentNullException(nameof(instance)); + } + + /// + public Task Create(InstanceUser user, CancellationToken cancellationToken) => apiClient.Create(Routes.InstanceUser, user, instance.Id, cancellationToken); + + public Task Delete(InstanceUser instanceUser, CancellationToken cancellationToken) => apiClient.Delete(Routes.SetID(Routes.InstanceUser, instanceUser.UserId.Value), instance.Id, cancellationToken); + + /// + public Task Read(CancellationToken cancellationToken) => apiClient.Read(Routes.InstanceUser, instance.Id, cancellationToken); + + /// + public Task Update(InstanceUser user, CancellationToken cancellationToken) => apiClient.Update(Routes.InstanceUser, user, instance.Id, cancellationToken); + + /// + public Task> List(CancellationToken cancellationToken) => apiClient.Read>(Routes.List(Routes.InstanceUser), instance.Id, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Tgstation.Server.Client/Components/JobsClient.cs b/src/Tgstation.Server.Client/Components/JobsClient.cs new file mode 100644 index 0000000000..359be4887b --- /dev/null +++ b/src/Tgstation.Server.Client/Components/JobsClient.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Client.Components +{ + /// + sealed class JobsClient : IJobsClient + { + /// + /// The for the + /// + readonly IApiClient apiClient; + /// + /// The for the + /// + readonly Instance instance; + + /// + /// Construct a + /// + /// The value of + /// The value of + public JobsClient(IApiClient apiClient, Instance instance) + { + this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); + this.instance = instance ?? throw new ArgumentNullException(nameof(instance)); + } + + /// + public Task Cancel(Job job, CancellationToken cancellationToken) => apiClient.Delete(Routes.SetID(Routes.Jobs, job.Id), instance.Id, cancellationToken); + + /// + public Task> List(CancellationToken cancellationToken) => apiClient.Read>(Routes.List(Routes.Jobs), instance.Id, cancellationToken); + + /// + public Task Read(Job job, CancellationToken cancellationToken) => apiClient.Read(Routes.SetID(Routes.Jobs, job.Id), instance.Id, cancellationToken); + + /// + public async Task CreateTaskFromJob(Job job, TimeSpan requeryRate, Action progressCallback, CancellationToken cancellationToken) + { + if (job == null) + throw new ArgumentNullException(nameof(job)); + + int? lastProgress = null; + while (!job.StoppedAt.HasValue) + { + await Task.Delay(requeryRate, cancellationToken).ConfigureAwait(false); + job = await Read(job, cancellationToken).ConfigureAwait(false); + if(job.Progress.HasValue && job.Progress != lastProgress) + { + progressCallback(job.Progress.Value); + lastProgress = job.Progress; + } + } + return job; + } + + } +} \ No newline at end of file diff --git a/src/Tgstation.Server.Client/Components/RepositoryClient.cs b/src/Tgstation.Server.Client/Components/RepositoryClient.cs new file mode 100644 index 0000000000..7b52a0cc1c --- /dev/null +++ b/src/Tgstation.Server.Client/Components/RepositoryClient.cs @@ -0,0 +1,40 @@ +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Client.Components +{ + /// + sealed class RepositoryClient : IRepositoryClient + { + /// + /// The for the + /// + readonly IApiClient apiClient; + /// + /// The for the + /// + readonly Instance instance; + + /// + /// Construct a + /// + /// The value of + /// + public RepositoryClient(IApiClient apiClient, Instance instance) + { + this.apiClient = apiClient; + this.instance = instance; + } + + /// + public Task Delete(CancellationToken cancellationToken) => apiClient.Delete(Routes.Repository, instance.Id, cancellationToken); + + /// + public Task Read(CancellationToken cancellationToken) => apiClient.Read(Routes.Repository, instance.Id, cancellationToken); + + /// + public Task Update(Repository repository, CancellationToken cancellationToken) => apiClient.Update(Routes.Repository, repository, instance.Id, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Tgstation.Server.Client/ConflictException.cs b/src/Tgstation.Server.Client/ConflictException.cs new file mode 100644 index 0000000000..f6f361b611 --- /dev/null +++ b/src/Tgstation.Server.Client/ConflictException.cs @@ -0,0 +1,38 @@ +using System; +using System.Net; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Client +{ + /// + /// Occurs when the client performs an action that would result in data conflict + /// + public sealed class ConflictException : ClientException + { + /// + /// Construct an with a and + /// + /// The for the + /// The for the + public ConflictException(ErrorMessage errorMessage, HttpStatusCode statusCode) : base(errorMessage, statusCode) + { } + + /// + /// Construct a + /// + public ConflictException() { } + + /// + /// Construct an with a + /// + /// The message for the + public ConflictException(string message) : base(message) { } + + /// + /// Construct an with a and + /// + /// The message for the + /// The inner for the base + public ConflictException(string message, Exception innerException) : base(message, innerException) { } + } +} \ No newline at end of file diff --git a/src/Tgstation.Server.Client/Components/IAdministrationClient.cs b/src/Tgstation.Server.Client/IAdministrationClient.cs similarity index 87% rename from src/Tgstation.Server.Client/Components/IAdministrationClient.cs rename to src/Tgstation.Server.Client/IAdministrationClient.cs index a68aeb5cfe..d420563fce 100644 --- a/src/Tgstation.Server.Client/Components/IAdministrationClient.cs +++ b/src/Tgstation.Server.Client/IAdministrationClient.cs @@ -1,14 +1,13 @@ using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api.Models; -using Tgstation.Server.Api.Rights; -namespace Tgstation.Server.Client.Components +namespace Tgstation.Server.Client { /// /// For managing server administration /// - public interface IAdministrationClient : IRightsClient + public interface IAdministrationClient { /// /// Get the represented by the diff --git a/src/Tgstation.Server.Client/IApiClient.cs b/src/Tgstation.Server.Client/IApiClient.cs new file mode 100644 index 0000000000..e2a4346396 --- /dev/null +++ b/src/Tgstation.Server.Client/IApiClient.cs @@ -0,0 +1,33 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api; + +namespace Tgstation.Server.Client +{ + /// + /// Web interface for the API + /// + interface IApiClient : IDisposable + { + ApiHeaders Headers { get; } + + Uri Url { get; } + + TimeSpan Timeout { get; set; } + + Task Create(string route, TBody body, CancellationToken cancellationToken); + Task Create(string route, CancellationToken cancellationToken); + Task Read(string route, CancellationToken cancellationToken); + Task Update(string route, TBody body, CancellationToken cancellationToken); + Task Update(string route, CancellationToken cancellationToken); + Task Update(string route, TBody body, CancellationToken cancellationToken); + Task Delete(string route, CancellationToken cancellationToken); + + Task Create(string route, TBody body, long instanceId, CancellationToken cancellationToken); + Task Create(string route, long instanceId, CancellationToken cancellationToken); + Task Read(string route, long instanceId, CancellationToken cancellationToken); + Task Update(string route, TBody body, long instanceId, CancellationToken cancellationToken); + Task Delete(string route, long instanceId, CancellationToken cancellationToken); + } +} diff --git a/src/Tgstation.Server.Client/IApiClientFactory.cs b/src/Tgstation.Server.Client/IApiClientFactory.cs new file mode 100644 index 0000000000..b8b5f91f96 --- /dev/null +++ b/src/Tgstation.Server.Client/IApiClientFactory.cs @@ -0,0 +1,19 @@ +using System; +using Tgstation.Server.Api; + +namespace Tgstation.Server.Client +{ + /// + /// For creating s + /// + interface IApiClientFactory + { + /// + /// Create an + /// + /// The base + /// The for the + /// A new + IApiClient CreateApiClient(Uri url, ApiHeaders apiHeaders); + } +} diff --git a/src/Tgstation.Server.Client/Components/IInstanceManagerClient.cs b/src/Tgstation.Server.Client/IInstanceManagerClient.cs similarity index 60% rename from src/Tgstation.Server.Client/Components/IInstanceManagerClient.cs rename to src/Tgstation.Server.Client/IInstanceManagerClient.cs index edc8fb393a..eadc471c0d 100644 --- a/src/Tgstation.Server.Client/Components/IInstanceManagerClient.cs +++ b/src/Tgstation.Server.Client/IInstanceManagerClient.cs @@ -2,37 +2,37 @@ using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api.Models; -using Tgstation.Server.Api.Rights; +using Tgstation.Server.Client.Components; -namespace Tgstation.Server.Client.Components +namespace Tgstation.Server.Client { /// /// For managing s /// - public interface IInstanceManagerClient : IRightsClient + public interface IInstanceManagerClient { /// /// Get all s for s the user can view /// /// The for the operation - /// A resulting in a of all s for s the user can view - Task> Read(CancellationToken cancellationToken); + /// A resulting in a of all s the user can view + Task> List(CancellationToken cancellationToken); /// /// Create an /// /// The to create. will be ignored /// The for the operation - /// A resulting in for the created - Task Create(Instance instance, CancellationToken cancellationToken); + /// A resulting in the created + Task Create(Instance instance, CancellationToken cancellationToken); /// /// Relocates, renamed, and/or on/offlines an /// /// The to update /// The for the operation - /// A representing the running operation - Task Update(Instance instance, CancellationToken cancellationToken); + /// A resulting in the updated + Task Update(Instance instance, CancellationToken cancellationToken); /// /// Deletes an @@ -41,5 +41,12 @@ namespace Tgstation.Server.Client.Components /// The for the operation /// A representing the running operation Task Delete(Instance instance, CancellationToken cancellationToken); + + /// + /// Create an for a given + /// + /// The to create an for + /// A new + IInstanceClient CreateClient(Instance instance); } } diff --git a/src/Tgstation.Server.Client/IRightsClient.cs b/src/Tgstation.Server.Client/IRightsClient.cs deleted file mode 100644 index bc32b24a35..0000000000 --- a/src/Tgstation.Server.Client/IRightsClient.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; - -namespace Tgstation.Server.Client -{ - /// - /// client that has a bitset - /// - /// The for the - public interface IRightsClient - { - /// - /// Get the for the - /// - /// The for the operation - /// A resulting in the for the - Task Rights(CancellationToken cancellationToken); - } -} diff --git a/src/Tgstation.Server.Client/IServerClient.cs b/src/Tgstation.Server.Client/IServerClient.cs index 510d609aeb..3fa1d13666 100644 --- a/src/Tgstation.Server.Client/IServerClient.cs +++ b/src/Tgstation.Server.Client/IServerClient.cs @@ -1,7 +1,7 @@ using System; using System.Threading; using System.Threading.Tasks; -using Tgstation.Server.Client.Components; +using Tgstation.Server.Api.Models; namespace Tgstation.Server.Client { @@ -11,24 +11,14 @@ namespace Tgstation.Server.Client public interface IServerClient : IDisposable { /// - /// The of the + /// The being used to access the server /// - Version Version { get; } + Token Token { get; } /// - /// The connection timeout in milliseconds. Defaults to 10000 + /// The connection timeout /// - int Timeout { get; set; } - - /// - /// How long to return initially cached models for in seconds. Defaults to 60 - /// - int CacheExpiry { get; set; } - - /// - /// The requery rate for job updates in milliseconds. Defaults to 5000 - /// - int RequeryRate { get; set; } + TimeSpan Timeout { get; set; } /// /// Access the @@ -41,15 +31,13 @@ namespace Tgstation.Server.Client IAdministrationClient Administration { get; } /// - /// These generally shouldn't be used in favor of the based polling and s other clients use. However, this is the only way to access jobs the client didn't start in it's current session + /// Access the /// - IJobsClient Jobs { get; } + IUsersClient Users { get; } /// - /// The of the connected server + /// The of the /// - /// A resulting in the of the connected server - /// Note that if the differs from the 's API functionality will most likely be compromised - Task GetServerVersion(CancellationToken cancellationToken); + Task Version(CancellationToken cancellationToken); } } diff --git a/src/Tgstation.Server.Client/IServerClientFactory.cs b/src/Tgstation.Server.Client/IServerClientFactory.cs index b3b3ecb506..df0427efef 100644 --- a/src/Tgstation.Server.Client/IServerClientFactory.cs +++ b/src/Tgstation.Server.Client/IServerClientFactory.cs @@ -1,5 +1,7 @@ -using System.Threading; +using System; +using System.Threading; using System.Threading.Tasks; +using Tgstation.Server.Api.Models; namespace Tgstation.Server.Client { @@ -11,24 +13,21 @@ namespace Tgstation.Server.Client /// /// Create a /// - /// The URL to access tgstation-server at + /// The URL to access TGS /// The username to for the /// The password for the - /// The initial timeout for the connection in milliseconds /// The for the operation + /// The representing timeout for the connection /// A resulting in a new - /// If the and/or is invalid - Task CreateServerClient(string hostname, string username, string password, int timeout, CancellationToken cancellationToken); + Task CreateServerClient(Uri host, string username, string password, TimeSpan timeout = default, CancellationToken cancellationToken = default); /// /// Create a /// - /// The URL to access tgstation-server at - /// The to access the API with - /// The initial timeout for the connection - /// The for the operation - /// A resulting in a new - /// If the invalid - Task CreateServerClient(string hostname, string token, int timeout, CancellationToken cancellationToken); + /// The URL to access TGS + /// The to access the API with + /// The representing timeout for the connection + /// A new + IServerClient CreateServerClient(Uri host, Token token, TimeSpan timeout = default); } } diff --git a/src/Tgstation.Server.Client/IUsersClient.cs b/src/Tgstation.Server.Client/IUsersClient.cs new file mode 100644 index 0000000000..6ea5e5fe31 --- /dev/null +++ b/src/Tgstation.Server.Client/IUsersClient.cs @@ -0,0 +1,35 @@ +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Client +{ + /// + /// For managing s + /// + public interface IUsersClient + { + /// + /// Read the current user's information and general rights + /// + /// The for the operation + /// + Task Read(CancellationToken cancellationToken); + + /// + /// Create a new + /// + /// The used to create the new + /// The for the operation + /// The new + Task Create(UserUpdate user, CancellationToken cancellationToken); + + /// + /// Update a + /// + /// The used to update the + /// The for the operation + /// The updated + Task Update(UserUpdate user, CancellationToken cancellationToken); + } +} \ No newline at end of file diff --git a/src/Tgstation.Server.Client/InstanceManagerClient.cs b/src/Tgstation.Server.Client/InstanceManagerClient.cs new file mode 100644 index 0000000000..b89056b302 --- /dev/null +++ b/src/Tgstation.Server.Client/InstanceManagerClient.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api; +using Tgstation.Server.Api.Models; +using Tgstation.Server.Client.Components; + +namespace Tgstation.Server.Client +{ + /// + sealed class InstanceManagerClient : IInstanceManagerClient + { + /// + /// The for the + /// + readonly IApiClient apiClient; + + /// + /// Map of already created s + /// + readonly Dictionary cachedClients; + + /// + /// Construct an + /// + /// + public InstanceManagerClient(IApiClient apiClient) + { + this.apiClient = apiClient; + + cachedClients = new Dictionary(); + } + + /// + public Task Create(Instance instance, CancellationToken cancellationToken) => apiClient.Create(Routes.InstanceManager, instance, cancellationToken); + + /// + public Task Delete(Instance instance, CancellationToken cancellationToken) => apiClient.Delete(Routes.SetID(Routes.InstanceManager, instance.Id), cancellationToken); + + /// + public Task> List(CancellationToken cancellationToken) => apiClient.Read>(Routes.List(Routes.InstanceManager), cancellationToken); + + /// + public Task Update(Instance instance, CancellationToken cancellationToken) => apiClient.Update(Routes.InstanceManager, instance, cancellationToken); + + /// + public IInstanceClient CreateClient(Instance instance) + { + if (!cachedClients.TryGetValue(instance.Id, out var client)) + { + client = new InstanceClient(apiClient, instance); + cachedClients.Add(instance.Id, client); + } + return client; + } + } +} \ No newline at end of file diff --git a/src/Tgstation.Server.Client/InsufficientPermissionsException.cs b/src/Tgstation.Server.Client/InsufficientPermissionsException.cs new file mode 100644 index 0000000000..b0f9ae21a7 --- /dev/null +++ b/src/Tgstation.Server.Client/InsufficientPermissionsException.cs @@ -0,0 +1,35 @@ +using System; +using System.Net; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Client +{ + /// + /// Occurs when the client attempts to perform an action they do not have the rights for + /// + public sealed class InsufficientPermissionsException : ClientException + { + /// + /// Construct an + /// + public InsufficientPermissionsException() : base(new ErrorMessage + { + Message = "The credentials provided do not have sufficient rights to make this request!", + SeverApiVersion = null + }, HttpStatusCode.Forbidden) + { } + + /// + /// Construct an with a + /// + /// The message for the + public InsufficientPermissionsException(string message) : base(message) { } + + /// + /// Construct an with a and + /// + /// The message for the + /// The inner for the base + public InsufficientPermissionsException(string message, Exception innerException) : base(message, innerException) { } + } +} \ No newline at end of file diff --git a/src/Tgstation.Server.Client/MethodNotSupportedException.cs b/src/Tgstation.Server.Client/MethodNotSupportedException.cs new file mode 100644 index 0000000000..23697a68f3 --- /dev/null +++ b/src/Tgstation.Server.Client/MethodNotSupportedException.cs @@ -0,0 +1,35 @@ +using System; +using System.Net; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Client +{ + /// + /// Occurs when the client tries to use a currently unsupported API + /// + public sealed class MethodNotSupportedException : ClientException + { + /// + /// Construct an + /// + public MethodNotSupportedException() : base(new ErrorMessage + { + Message = "This method is not currently supported!", + SeverApiVersion = null + }, HttpStatusCode.NotImplemented) + { } + + /// + /// Construct an with a + /// + /// The message for the + public MethodNotSupportedException(string message) : base(message) { } + + /// + /// Construct an with a and + /// + /// The message for the + /// The inner for the base + public MethodNotSupportedException(string message, Exception innerException) : base(message, innerException) { } + } +} \ No newline at end of file diff --git a/src/Tgstation.Server.Client/RateLimitException.cs b/src/Tgstation.Server.Client/RateLimitException.cs new file mode 100644 index 0000000000..8d92b316d4 --- /dev/null +++ b/src/Tgstation.Server.Client/RateLimitException.cs @@ -0,0 +1,42 @@ +using System; +using System.Net; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Client +{ + /// + /// Occurs when a GitHub rate limit occurs + /// + public sealed class RateLimitException : ClientException + { + DateTimeOffset RetryAfter { get; } + + /// + /// Construct an + /// + public RateLimitException() { } + + /// + /// Construct an with a + /// + /// The message for the + public RateLimitException(string secondsString) : base(new ErrorMessage + { + Message = "GitHub rate limit reached!", + SeverApiVersion = null + }, (HttpStatusCode)429) + { + if (Int32.TryParse(secondsString, out var seconds) && seconds >= 0) + RetryAfter = DateTimeOffset.Now.AddSeconds(seconds); + else + RetryAfter = DateTimeOffset.Now; + } + + /// + /// Construct an with a and + /// + /// The message for the + /// The inner for the base + public RateLimitException(string message, Exception innerException) : base(message, innerException) { } + } +} \ No newline at end of file diff --git a/src/Tgstation.Server.Client/RequestTimeoutException.cs b/src/Tgstation.Server.Client/RequestTimeoutException.cs new file mode 100644 index 0000000000..b300e1d579 --- /dev/null +++ b/src/Tgstation.Server.Client/RequestTimeoutException.cs @@ -0,0 +1,35 @@ +using System; +using System.Net; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Client +{ + /// + /// Occurs when the client provides invalid credentials + /// + public sealed class RequestTimeoutException : ClientException + { + /// + /// Construct an + /// + public RequestTimeoutException() : base(new ErrorMessage + { + Message = "The request timed out!", + SeverApiVersion = null + }, HttpStatusCode.RequestTimeout) + { } + + /// + /// Construct an with a + /// + /// The message for the + public RequestTimeoutException(string message) : base(message) { } + + /// + /// Construct an with a and + /// + /// The message for the + /// The inner for the base + public RequestTimeoutException(string message, Exception innerException) : base(message, innerException) { } + } +} \ No newline at end of file diff --git a/src/Tgstation.Server.Client/ServerClient.cs b/src/Tgstation.Server.Client/ServerClient.cs new file mode 100644 index 0000000000..a592c3630d --- /dev/null +++ b/src/Tgstation.Server.Client/ServerClient.cs @@ -0,0 +1,59 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Client +{ + /// + sealed class ServerClient : IServerClient + { + /// + public Token Token { get; } + + /// + public TimeSpan Timeout + { + get => apiClient.Timeout; + set => apiClient.Timeout = value; + } + + /// + public IInstanceManagerClient Instances { get; } + + /// + public IAdministrationClient Administration { get; } + + /// + public IUsersClient Users { get; } + + /// + /// The for the + /// + readonly IApiClient apiClient; + + /// + /// Construct a + /// + /// The value of + /// The value of + public ServerClient(IApiClient apiClient, Token token) + { + this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); + Token = token ?? throw new ArgumentNullException(nameof(token)); + + if (Token.Bearer != apiClient.Headers.Token) + throw new ArgumentOutOfRangeException(nameof(token), token, "Provided token does not match apiClient headers!"); + + Instances = new InstanceManagerClient(apiClient); + Users = new UsersClient(apiClient); + Administration = new AdministrationClient(apiClient); + } + + /// + public void Dispose() => apiClient.Dispose(); + /// + public Task Version(CancellationToken cancellationToken) => apiClient.Read(Routes.Root, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Tgstation.Server.Client/ServerClientFactory.cs b/src/Tgstation.Server.Client/ServerClientFactory.cs index 1d4af06329..bf44e1754f 100644 --- a/src/Tgstation.Server.Client/ServerClientFactory.cs +++ b/src/Tgstation.Server.Client/ServerClientFactory.cs @@ -1,16 +1,67 @@ using System; +using System.Net.Http.Headers; using System.Threading; using System.Threading.Tasks; +using Tgstation.Server.Api; +using Tgstation.Server.Api.Models; namespace Tgstation.Server.Client { /// public sealed class ServerClientFactory : IServerClientFactory { + /// + /// The for the + /// + static readonly IApiClientFactory apiClientFactory = new ApiClientFactory(); + + /// + /// The for the + /// + readonly ProductHeaderValue productHeaderValue; + + /// + /// Construct a + /// + /// The value of + public ServerClientFactory(ProductHeaderValue productHeaderValue) + { + this.productHeaderValue = productHeaderValue ?? throw new ArgumentNullException(nameof(productHeaderValue)); + } + /// - public Task CreateServerClient(string hostname, string username, string password, int timeout, CancellationToken cancellationToken) => throw new NotImplementedException(); + public async Task CreateServerClient(Uri host, string username, string password, TimeSpan timeout, CancellationToken cancellationToken) + { + if (host == null) + throw new ArgumentNullException(nameof(host)); + if (username == null) + throw new ArgumentNullException(nameof(username)); + if (password == null) + throw new ArgumentNullException(nameof(password)); + if (timeout == null) + throw new ArgumentNullException(nameof(timeout)); + + Token token; + using (var api = apiClientFactory.CreateApiClient(host, new ApiHeaders(productHeaderValue, username, password))) + { + if (timeout != default) + api.Timeout = timeout; + token = await api.Update(Routes.Root, cancellationToken).ConfigureAwait(false); + } + return CreateServerClient(host, token, timeout); + } /// - public Task CreateServerClient(string hostname, string token, int timeout, CancellationToken cancellationToken) => throw new NotImplementedException(); + public IServerClient CreateServerClient(Uri host, Token token, TimeSpan timeout) + { + if (host == null) + throw new ArgumentNullException(nameof(host)); + if (token == null) + throw new ArgumentNullException(nameof(token)); + var result = new ServerClient(apiClientFactory.CreateApiClient(host, new ApiHeaders(productHeaderValue, token.Bearer)), token); + if (timeout != default) + result.Timeout = timeout; + return result; + } } } diff --git a/src/Tgstation.Server.Client/ServerErrorException.cs b/src/Tgstation.Server.Client/ServerErrorException.cs new file mode 100644 index 0000000000..f5a243678c --- /dev/null +++ b/src/Tgstation.Server.Client/ServerErrorException.cs @@ -0,0 +1,37 @@ +using System; +using System.Net; + +namespace Tgstation.Server.Client +{ + /// + /// Occurs when an error occurs in the server + /// + public sealed class ServerErrorException : ClientException + { + /// + /// The raw HTML of the error + /// + public string Html { get; } + + /// + /// Construct an + /// + public ServerErrorException() { } + + /// + /// Construct an with + /// + /// The raw HTML response of the + public ServerErrorException(string html) : base(null, HttpStatusCode.InternalServerError) + { + Html = html; + } + + /// + /// Construct an with a and + /// + /// The message for the + /// The inner for the base + public ServerErrorException(string message, Exception innerException) : base(message, innerException) { } + } +} \ No newline at end of file diff --git a/src/Tgstation.Server.Client/Tgstation.Server.Client.csproj b/src/Tgstation.Server.Client/Tgstation.Server.Client.csproj index 141da8fae0..46b170baca 100644 --- a/src/Tgstation.Server.Client/Tgstation.Server.Client.csproj +++ b/src/Tgstation.Server.Client/Tgstation.Server.Client.csproj @@ -30,6 +30,10 @@ latest + + + + diff --git a/src/Tgstation.Server.Client/UnauthorizedException.cs b/src/Tgstation.Server.Client/UnauthorizedException.cs new file mode 100644 index 0000000000..f439de3597 --- /dev/null +++ b/src/Tgstation.Server.Client/UnauthorizedException.cs @@ -0,0 +1,35 @@ +using System; +using System.Net; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Client +{ + /// + /// Occurs when the client provides invalid credentials + /// + public sealed class UnauthorizedException : ClientException + { + /// + /// Construct an + /// + public UnauthorizedException() : base(new ErrorMessage + { + Message = "Invalid credentials!", + SeverApiVersion = null + }, HttpStatusCode.Unauthorized) + { } + + /// + /// Construct an with a + /// + /// The message for the + public UnauthorizedException(string message) : base(message) { } + + /// + /// Construct an with a and + /// + /// The message for the + /// The inner for the base + public UnauthorizedException(string message, Exception innerException) : base(message, innerException) { } + } +} \ No newline at end of file diff --git a/src/Tgstation.Server.Client/UsersClient.cs b/src/Tgstation.Server.Client/UsersClient.cs new file mode 100644 index 0000000000..39d1b68646 --- /dev/null +++ b/src/Tgstation.Server.Client/UsersClient.cs @@ -0,0 +1,35 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Client +{ + /// + sealed class UsersClient : IUsersClient + { + /// + /// The for the + /// + readonly IApiClient apiClient; + + /// + /// Construct an + /// + /// The value of + public UsersClient(IApiClient apiClient) + { + this.apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); + } + + /// + public Task Create(UserUpdate user, CancellationToken cancellationToken) => apiClient.Create(Routes.User, user, cancellationToken); + + /// + public Task Read(CancellationToken cancellationToken) => apiClient.Read(Routes.User, cancellationToken); + + /// + public Task Update(UserUpdate user, CancellationToken cancellationToken) => apiClient.Update(Routes.User, user, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Tgstation.Server.Host/Components/Chat/Chat.cs b/src/Tgstation.Server.Host/Components/Chat/Chat.cs index 7178a1e2cd..983155cad5 100644 --- a/src/Tgstation.Server.Host/Components/Chat/Chat.cs +++ b/src/Tgstation.Server.Host/Components/Chat/Chat.cs @@ -44,7 +44,7 @@ namespace Tgstation.Server.Host.Components.Chat readonly Dictionary builtinCommands; /// - /// Map of s in use, keyed by + /// Map of s in use, keyed by /// readonly Dictionary providers; @@ -64,9 +64,9 @@ namespace Tgstation.Server.Host.Components.Chat readonly CancellationTokenSource handlerCts; /// - /// The initial for the + /// The initial for the /// - readonly List initialChatSettings; + readonly List initialChatBots; /// /// The for the @@ -79,7 +79,7 @@ namespace Tgstation.Server.Host.Components.Chat Task chatHandler; /// - /// The that completes when change + /// The that completes when s change /// TaskCompletionSource connectionsUpdated; @@ -100,14 +100,14 @@ namespace Tgstation.Server.Host.Components.Chat /// The value of /// The value of /// The value of - /// The used to populate - public Chat(IProviderFactory providerFactory, IIOManager ioManager, ICommandFactory commandFactory, ILogger logger, IEnumerable initialChatSettings) + /// The used to populate + public Chat(IProviderFactory providerFactory, IIOManager ioManager, ICommandFactory commandFactory, ILogger logger, IEnumerable initialChatBots) { this.providerFactory = providerFactory ?? throw new ArgumentNullException(nameof(providerFactory)); this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager)); this.commandFactory = commandFactory ?? throw new ArgumentNullException(nameof(commandFactory)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - this.initialChatSettings = initialChatSettings?.ToList() ?? throw new ArgumentNullException(nameof(initialChatSettings)); + this.initialChatBots = initialChatBots?.ToList() ?? throw new ArgumentNullException(nameof(initialChatBots)); builtinCommands = new Dictionary(); providers = new Dictionary(); @@ -129,7 +129,7 @@ namespace Tgstation.Server.Host.Components.Chat /// /// Remove a from and optionally updating the as well /// - /// The of the to delete + /// The of the to delete /// If should be update /// The for the operation /// A resulting in the being removed if it exists, otherwise @@ -164,8 +164,6 @@ namespace Tgstation.Server.Host.Components.Chat /// A representing the running operation async Task ProcessMessage(IProvider provider, Message message, CancellationToken cancellationToken) { - logger.LogTrace("Chat message: {0}. User (Note unconverted provider Id): {1}", message.Content, JsonConvert.SerializeObject(message.User)); - //map the channel if it's private and we haven't seen it if (message.User.Channel.IsPrivate) lock (providers) @@ -205,6 +203,8 @@ namespace Tgstation.Server.Host.Components.Chat //no mention return; + logger.LogTrace("Chat command: {0}. User (True provider Id): {1}", message.Content, JsonConvert.SerializeObject(message.User)); + if (addressed) splits.RemoveAt(0); @@ -375,7 +375,7 @@ namespace Tgstation.Server.Host.Components.Chat } /// - public async Task ChangeSettings(ChatSettings newSettings, CancellationToken cancellationToken) + public async Task ChangeSettings(ChatBot newSettings, CancellationToken cancellationToken) { if (newSettings == null) throw new ArgumentNullException(nameof(newSettings)); @@ -478,9 +478,9 @@ namespace Tgstation.Server.Host.Components.Chat { foreach (var I in commandFactory.GenerateCommands()) builtinCommands.Add(I.Name.ToUpperInvariant(), I); - await Task.WhenAll(initialChatSettings.Select(x => ChangeSettings(x, cancellationToken))).ConfigureAwait(false); + await Task.WhenAll(initialChatBots.Select(x => ChangeSettings(x, cancellationToken))).ConfigureAwait(false); await Task.WhenAll(providers.Select(x => x.Value).Select(x => x.Connect(cancellationToken))).ConfigureAwait(false); - await Task.WhenAll(initialChatSettings.Select(x => ChangeChannels(x.Id, x.Channels, cancellationToken))).ConfigureAwait(false); + await Task.WhenAll(initialChatBots.Select(x => ChangeChannels(x.Id, x.Channels, cancellationToken))).ConfigureAwait(false); chatHandler = MonitorMessages(handlerCts.Token); started = true; } diff --git a/src/Tgstation.Server.Host/Components/Chat/ChatFactory.cs b/src/Tgstation.Server.Host/Components/Chat/ChatFactory.cs index cd23992477..8e4ab3407b 100644 --- a/src/Tgstation.Server.Host/Components/Chat/ChatFactory.cs +++ b/src/Tgstation.Server.Host/Components/Chat/ChatFactory.cs @@ -45,6 +45,6 @@ namespace Tgstation.Server.Host.Components.Chat } /// - public IChat CreateChat(IEnumerable initialChatSettings) => new Chat(providerFactory, ioManager, commandFactory, loggerFactory.CreateLogger(), initialChatSettings); + public IChat CreateChat(IEnumerable initialChatBots) => new Chat(providerFactory, ioManager, commandFactory, loggerFactory.CreateLogger(), initialChatBots); } } diff --git a/src/Tgstation.Server.Host/Components/Chat/IChat.cs b/src/Tgstation.Server.Host/Components/Chat/IChat.cs index 114669bbca..fb8a1166df 100644 --- a/src/Tgstation.Server.Host/Components/Chat/IChat.cs +++ b/src/Tgstation.Server.Host/Components/Chat/IChat.cs @@ -13,9 +13,9 @@ namespace Tgstation.Server.Host.Components.Chat public interface IChat : IHostedService, IDisposable { /// - /// If a given set of is connected + /// If a given set of is connected /// - /// The of the connection + /// The of the connection /// if it is connected, otherwise bool Connected(long connectionId); @@ -26,17 +26,17 @@ namespace Tgstation.Server.Host.Components.Chat void RegisterCommandHandler(ICustomCommandHandler customCommandHandler); /// - /// Change chat settings. If the is not currently in use, a new connection will be made instead + /// Change chat settings. If the is not currently in use, a new connection will be made instead /// - /// The new + /// The new /// The for the operation - /// A representing the running operation. Will complete immediately if the property of is - Task ChangeSettings(ChatSettings newSettings, CancellationToken cancellationToken); + /// A representing the running operation. Will complete immediately if the property of is + Task ChangeSettings(ChatBot newSettings, CancellationToken cancellationToken); /// /// Disconnects and deletes a given connection /// - /// The of the connection + /// The of the connection /// The for the operation /// A representing the running operation Task DeleteConnection(long connectionId, CancellationToken cancellationToken); @@ -44,7 +44,7 @@ namespace Tgstation.Server.Host.Components.Chat /// /// Change chat channels /// - /// The of the connection + /// The of the connection /// An of the new list of s /// The for the operation /// A representing the running operation diff --git a/src/Tgstation.Server.Host/Components/Chat/IChatFactory.cs b/src/Tgstation.Server.Host/Components/Chat/IChatFactory.cs index 44802b484f..6950552eb7 100644 --- a/src/Tgstation.Server.Host/Components/Chat/IChatFactory.cs +++ b/src/Tgstation.Server.Host/Components/Chat/IChatFactory.cs @@ -10,8 +10,8 @@ namespace Tgstation.Server.Host.Components.Chat /// /// Create a /// - /// The initial for the + /// The initial for the /// A new - IChat CreateChat(IEnumerable initialChatSettings); + IChat CreateChat(IEnumerable initialChatBots); } } diff --git a/src/Tgstation.Server.Host/Components/Chat/IProviderFactory.cs b/src/Tgstation.Server.Host/Components/Chat/IProviderFactory.cs index b4b47f2d5b..2bf9b1651d 100644 --- a/src/Tgstation.Server.Host/Components/Chat/IProviderFactory.cs +++ b/src/Tgstation.Server.Host/Components/Chat/IProviderFactory.cs @@ -11,8 +11,8 @@ namespace Tgstation.Server.Host.Components.Chat /// /// Create a /// - /// The for the new provider + /// The containing settings for the new provider /// A new - IProvider CreateProvider(ChatSettings settings); + IProvider CreateProvider(ChatBot settings); } } diff --git a/src/Tgstation.Server.Host/Components/Chat/ProviderFactory.cs b/src/Tgstation.Server.Host/Components/Chat/ProviderFactory.cs index bb3b6fd20f..ae9633671d 100644 --- a/src/Tgstation.Server.Host/Components/Chat/ProviderFactory.cs +++ b/src/Tgstation.Server.Host/Components/Chat/ProviderFactory.cs @@ -33,7 +33,7 @@ namespace Tgstation.Server.Host.Components.Chat } /// - public IProvider CreateProvider(Api.Models.Internal.ChatSettings settings) + public IProvider CreateProvider(Api.Models.Internal.ChatBot settings) { if (settings == null) throw new ArgumentNullException(nameof(settings)); diff --git a/src/Tgstation.Server.Host/Components/Compiler/DreamMaker.cs b/src/Tgstation.Server.Host/Components/Compiler/DreamMaker.cs index a269fcea9d..3adfbf0405 100644 --- a/src/Tgstation.Server.Host/Components/Compiler/DreamMaker.cs +++ b/src/Tgstation.Server.Host/Components/Compiler/DreamMaker.cs @@ -80,7 +80,7 @@ namespace Tgstation.Server.Host.Components.Compiler /// The for /// readonly ILogger logger; - + /// /// Construct /// @@ -115,15 +115,16 @@ namespace Tgstation.Server.Host.Components.Compiler /// The level to use to validate the API /// The for the operation /// The current + /// The port to use for API validation /// The for the operation /// A resulting in if the DMAPI was successfully validated, otherwise - async Task VerifyApi(uint timeout, DreamDaemonSecurity securityLevel, Models.CompileJob job, IByondExecutableLock byondLock, CancellationToken cancellationToken) + async Task VerifyApi(uint timeout, DreamDaemonSecurity securityLevel, Models.CompileJob job, IByondExecutableLock byondLock, ushort portToUse, CancellationToken cancellationToken) { - logger.LogTrace("Verifying DMAPI..."); + logger.LogTrace("Verifying DMAPI..."); var launchParameters = new DreamDaemonLaunchParameters { AllowWebClient = false, - PrimaryPort = 0, //pick any port + PrimaryPort = portToUse, SecurityLevel = securityLevel, //all it needs to read the file and exit StartupTimeout = timeout }; @@ -229,8 +230,14 @@ namespace Tgstation.Server.Host.Components.Compiler } /// - public async Task Compile(Models.RevisionInformation revisionInformation, string projectName, DreamDaemonSecurity securityLevel, uint apiValidateTimeout, IRepository repository, CancellationToken cancellationToken) + public async Task Compile(Models.RevisionInformation revisionInformation, DreamMakerSettings dreamMakerSettings, DreamDaemonSecurity securityLevel, uint apiValidateTimeout, IRepository repository, CancellationToken cancellationToken) { + if (revisionInformation == null) + throw new ArgumentNullException(nameof(revisionInformation)); + + if (dreamMakerSettings == null) + throw new ArgumentNullException(nameof(dreamMakerSettings)); + if (repository == null) throw new ArgumentNullException(nameof(repository)); @@ -242,7 +249,7 @@ namespace Tgstation.Server.Host.Components.Compiler var job = new Models.CompileJob { DirectoryName = Guid.NewGuid(), - DmeName = projectName, + DmeName = dreamMakerSettings.ProjectName, RevisionInformation = revisionInformation }; @@ -260,12 +267,15 @@ namespace Tgstation.Server.Host.Components.Compiler Status = CompilerStatus.Copying; } - var commitInsert = revisionInformation.CommitSha; - var remoteCommitInsert = String.Empty; - if (commitInsert == revisionInformation.OriginCommitSha) - commitInsert = String.Format(CultureInfo.InvariantCulture, "^{0}", commitInsert.Substring(0, 7)); + var commitInsert = revisionInformation.CommitSha.Substring(0, 7); + string remoteCommitInsert; + if (revisionInformation.CommitSha == revisionInformation.OriginCommitSha) + { + commitInsert = String.Format(CultureInfo.InvariantCulture, "^{0}", commitInsert); + remoteCommitInsert = String.Empty; + } else - remoteCommitInsert = String.Format(CultureInfo.InvariantCulture, ". Remote commit: ^{0}", revisionInformation.OriginCommitSha); + remoteCommitInsert = String.Format(CultureInfo.InvariantCulture, ". Remote commit: ^{0}", revisionInformation.OriginCommitSha.Substring(0, 7)); var testmergeInsert = revisionInformation.ActiveTestMerges.Count == 0 ? String.Empty : String.Format(CultureInfo.InvariantCulture, " (Test Merges: {0})", String.Join(", ", revisionInformation.ActiveTestMerges.Select(x => x.TestMerge).Select(x => @@ -286,11 +296,11 @@ namespace Tgstation.Server.Host.Components.Compiler var dirA = ioManager.ConcatPath(job.DirectoryName.ToString(), ADirectoryName); var dirB = ioManager.ConcatPath(job.DirectoryName.ToString(), BDirectoryName); - async Task CleanupFailedCompile() + async Task CleanupFailedCompile(bool announce) { logger.LogTrace("Cleaning compile directory..."); Status = CompilerStatus.Cleanup; - var chatTask = chat.SendUpdateMessage("DM: Deploy failed!", cancellationToken); + var chatTask = announce ? chat.SendUpdateMessage("Deploy failed!", cancellationToken) : Task.CompletedTask; try { await ioManager.DeleteDirectory(job.DirectoryName.ToString(), CancellationToken.None).ConfigureAwait(false); @@ -314,7 +324,8 @@ namespace Tgstation.Server.Host.Components.Compiler Status = CompilerStatus.PreCompile; - await eventConsumer.HandleEvent(EventType.CompileStart, new List { ioManager.ResolvePath(ioManager.ConcatPath(job.DirectoryName.ToString(), ADirectoryName)), repoOrigin }, cancellationToken).ConfigureAwait(false); + var resolvedGameDirectory = ioManager.ResolvePath(ioManager.ConcatPath(job.DirectoryName.ToString(), ADirectoryName)); + await eventConsumer.HandleEvent(EventType.CompileStart, new List { resolvedGameDirectory, repoOrigin }, cancellationToken).ConfigureAwait(false); Status = CompilerStatus.Modifying; @@ -339,53 +350,51 @@ namespace Tgstation.Server.Host.Components.Compiler Status = CompilerStatus.Compiling; //run compiler, verify api - bool ddVerified; job.ByondVersion = byondLock.Version.ToString(); await RunDreamMaker(byondLock.DreamMakerPath, job, cancellationToken).ConfigureAwait(false); - Status = CompilerStatus.Verifying; + if (job.ExitCode == 0) + { + Status = CompilerStatus.Verifying; - ddVerified = job.ExitCode == 0 && await VerifyApi(apiValidateTimeout, securityLevel, job, byondLock, cancellationToken).ConfigureAwait(false); + job.DMApiValidated = await VerifyApi(apiValidateTimeout, securityLevel, job, byondLock, dreamMakerSettings.ApiValidationPort.Value, cancellationToken).ConfigureAwait(false); + } - if (!ddVerified) + if (job.DMApiValidated != true) { //server never validated or compile failed - await CleanupFailedCompile().ConfigureAwait(false); - await eventConsumer.HandleEvent(EventType.CompileFailure, new List { job.ExitCode == 0 ? "1" : "0" }, cancellationToken).ConfigureAwait(false); + await eventConsumer.HandleEvent(EventType.CompileFailure, new List { resolvedGameDirectory, job.ExitCode == 0 ? "1" : "0" }, cancellationToken).ConfigureAwait(false); + throw new Exception(job.ExitCode == 0 ? "Validation of the TGS api failed!" : "DM exited with a non-zero code!"); } - else - { - job.DMApiValidated = true; - logger.LogTrace("Running post compile event..."); - Status = CompilerStatus.PostCompile; - await eventConsumer.HandleEvent(EventType.CompileComplete, new List { ioManager.ResolvePath(ioManager.ConcatPath(job.DirectoryName.ToString(), ADirectoryName)) }, cancellationToken).ConfigureAwait(false); + logger.LogTrace("Running post compile event..."); + Status = CompilerStatus.PostCompile; + await eventConsumer.HandleEvent(EventType.CompileComplete, new List { ioManager.ResolvePath(ioManager.ConcatPath(job.DirectoryName.ToString(), ADirectoryName)) }, cancellationToken).ConfigureAwait(false); - logger.LogTrace("Duplicating compiled game..."); - Status = CompilerStatus.Duplicating; + logger.LogTrace("Duplicating compiled game..."); + Status = CompilerStatus.Duplicating; - //duplicate the dmb et al - await ioManager.CopyDirectory(dirA, dirB, null, cancellationToken).ConfigureAwait(false); + //duplicate the dmb et al + await ioManager.CopyDirectory(dirA, dirB, null, cancellationToken).ConfigureAwait(false); - logger.LogTrace("Applying static game file symlinks..."); - Status = CompilerStatus.Symlinking; + logger.LogTrace("Applying static game file symlinks..."); + Status = CompilerStatus.Symlinking; - //symlink in the static data - var symATask = configuration.SymlinkStaticFilesTo(fullDirA, cancellationToken); - var symBTask = configuration.SymlinkStaticFilesTo(ioManager.ResolvePath(dirB), cancellationToken); + //symlink in the static data + var symATask = configuration.SymlinkStaticFilesTo(fullDirA, cancellationToken); + var symBTask = configuration.SymlinkStaticFilesTo(ioManager.ResolvePath(dirB), cancellationToken); - await Task.WhenAll(symATask, symBTask).ConfigureAwait(false); + await Task.WhenAll(symATask, symBTask).ConfigureAwait(false); - await chat.SendUpdateMessage("Deployment complete! Changes will be applied on next server reboot.", cancellationToken).ConfigureAwait(false); + await chat.SendUpdateMessage("Deployment complete! Changes will be applied on next server reboot.", cancellationToken).ConfigureAwait(false); - logger.LogDebug("Compile complete!"); - } + logger.LogDebug("Compile complete!"); return job; } - catch + catch (Exception e) { - await CleanupFailedCompile().ConfigureAwait(false); + await CleanupFailedCompile(!(e is OperationCanceledException)).ConfigureAwait(false); throw; } } diff --git a/src/Tgstation.Server.Host/Components/Compiler/IDreamMaker.cs b/src/Tgstation.Server.Host/Components/Compiler/IDreamMaker.cs index 9bbc9eda84..c2d1f3fb6c 100644 --- a/src/Tgstation.Server.Host/Components/Compiler/IDreamMaker.cs +++ b/src/Tgstation.Server.Host/Components/Compiler/IDreamMaker.cs @@ -1,6 +1,7 @@ using System.Threading; using System.Threading.Tasks; using Tgstation.Server.Api.Models; +using Tgstation.Server.Api.Models.Internal; using Tgstation.Server.Host.Components.Repository; namespace Tgstation.Server.Host.Components.Compiler @@ -19,12 +20,12 @@ namespace Tgstation.Server.Host.Components.Compiler /// Starts a compile /// /// The being compiled from the - /// The optional name of the .dme to compile without the extension if not pre + /// The for the compile /// The level allowed for API validation /// The time in seconds to wait while validating the API /// The to copy from /// The for the operation - /// A resulting in the partially populated for the operation. In particular, note the field will only have it's field populated - Task Compile(Models.RevisionInformation revisionInformation, string projectName, DreamDaemonSecurity securityLevel, uint apiValidateTimeout, IRepository repository, CancellationToken cancellationToken); + /// A resulting in the partially populated for the operation. In particular, note the field will only have it's field populated + Task Compile(Models.RevisionInformation revisionInformation, DreamMakerSettings dreamMakerSettings, DreamDaemonSecurity securityLevel, uint apiValidateTimeout, IRepository repository, CancellationToken cancellationToken); } } \ No newline at end of file diff --git a/src/Tgstation.Server.Host/Components/EventType.cs b/src/Tgstation.Server.Host/Components/EventType.cs index 57de1c2bac..e60a1661a5 100644 --- a/src/Tgstation.Server.Host/Components/EventType.cs +++ b/src/Tgstation.Server.Host/Components/EventType.cs @@ -47,7 +47,7 @@ /// CompileCancelled = 9, /// - /// Parameters: "1" if compile succeeded and api validation failed, "0" otherwise + /// Parameters: Game directory path, "1" if compile succeeded and api validation failed, "0" otherwise /// CompileFailure = 10, /// diff --git a/src/Tgstation.Server.Host/Components/Instance.cs b/src/Tgstation.Server.Host/Components/Instance.cs index 59de36fb74..98d88ce16b 100644 --- a/src/Tgstation.Server.Host/Components/Instance.cs +++ b/src/Tgstation.Server.Host/Components/Instance.cs @@ -133,8 +133,8 @@ namespace Tgstation.Server.Host.Components StartupTimeout = x.StartupTimeout, SecurityLevel = x.SecurityLevel }).FirstAsync(cancellationToken); - var projectNameTask = instanceQuery.Select(x => x.DreamMakerSettings.ProjectName).FirstOrDefaultAsync(cancellationToken); - var repositorySettingsTask = instanceQuery.Select(x => x.RepositorySettings).FirstOrDefaultAsync(cancellationToken); + var dmSettingsTask = instanceQuery.Select(x => x.DreamMakerSettings).FirstAsync(cancellationToken); + var repositorySettingsTask = instanceQuery.Select(x => x.RepositorySettings).FirstAsync(cancellationToken); using (var repo = await RepositoryManager.LoadRepository(cancellationToken).ConfigureAwait(false)) { @@ -169,7 +169,7 @@ namespace Tgstation.Server.Host.Components await repo.Sychronize(repositorySettings.AccessUser, repositorySettings.AccessToken, shouldSyncTracked, cancellationToken).ConfigureAwait(false); //finish other queries - var projectName = await projectNameTask.ConfigureAwait(false); + var dmSettings = await dmSettingsTask.ConfigureAwait(false); var ddSettings = await ddSettingsTask.ConfigureAwait(false); var revInfo = await revInfoTask.ConfigureAwait(false); @@ -190,7 +190,7 @@ namespace Tgstation.Server.Host.Components } //finally start compile - job = await DreamMaker.Compile(revInfo, projectName, ddSettings.SecurityLevel.Value, ddSettings.StartupTimeout.Value, repo, cancellationToken).ConfigureAwait(false); + job = await DreamMaker.Compile(revInfo, dmSettings, ddSettings.SecurityLevel.Value, ddSettings.StartupTimeout.Value, repo, cancellationToken).ConfigureAwait(false); } db.CompileJobs.Add(job); diff --git a/src/Tgstation.Server.Host/Components/Interop/CommContext.cs b/src/Tgstation.Server.Host/Components/Interop/CommContext.cs index 19ee6d7777..515c30a882 100644 --- a/src/Tgstation.Server.Host/Components/Interop/CommContext.cs +++ b/src/Tgstation.Server.Host/Components/Interop/CommContext.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Logging; using Newtonsoft.Json; using System; +using System.Collections.Generic; using System.IO; using System.Text; using System.Threading; @@ -96,7 +97,10 @@ namespace Tgstation.Server.Host.Components.Interop CommCommand command; try { - command = JsonConvert.DeserializeObject(file); + command = new CommCommand + { + Parameters = JsonConvert.DeserializeObject>(file) + }; } catch (JsonSerializationException ex) { diff --git a/src/Tgstation.Server.Host/Components/Repository/IRepositoryManager.cs b/src/Tgstation.Server.Host/Components/Repository/IRepositoryManager.cs index a8cb1fd123..ab5fcc0deb 100644 --- a/src/Tgstation.Server.Host/Components/Repository/IRepositoryManager.cs +++ b/src/Tgstation.Server.Host/Components/Repository/IRepositoryManager.cs @@ -9,6 +9,11 @@ namespace Tgstation.Server.Host.Components.Repository /// public interface IRepositoryManager : IDisposable { + /// + /// If a operation is in progress + /// + bool CloneInProgress { get; } + /// /// Attempt to load the from the default location /// diff --git a/src/Tgstation.Server.Host/Components/Repository/RepositoryManager.cs b/src/Tgstation.Server.Host/Components/Repository/RepositoryManager.cs index fb861acaa6..b69e3d303b 100644 --- a/src/Tgstation.Server.Host/Components/Repository/RepositoryManager.cs +++ b/src/Tgstation.Server.Host/Components/Repository/RepositoryManager.cs @@ -11,6 +11,9 @@ namespace Tgstation.Server.Host.Components.Repository /// sealed class RepositoryManager : IRepositoryManager { + /// + public bool CloneInProgress { get; private set; } + /// /// The for the /// @@ -51,56 +54,72 @@ namespace Tgstation.Server.Host.Components.Repository /// public async Task CloneRepository(Uri url, string initialBranch, string username, string password, Action progressReporter, CancellationToken cancellationToken) { - using (await SemaphoreSlimContext.Lock(semaphore, cancellationToken).ConfigureAwait(false)) - if (!await ioManager.DirectoryExists(".", cancellationToken).ConfigureAwait(false)) - try - { - await Task.Factory.StartNew(() => - { - string path = null; - try - { - path = LibGit2Sharp.Repository.Clone(url.ToString(), ioManager.ResolvePath("."), new CloneOptions - { - OnProgress = (a) => !cancellationToken.IsCancellationRequested, - OnTransferProgress = (a) => - { - var percentage = 100 * (((float)a.IndexedObjects + a.ReceivedObjects) / (a.TotalObjects * 2)); - progressReporter((int)percentage); - return !cancellationToken.IsCancellationRequested; - }, - RecurseSubmodules = true, - OnUpdateTips = (a, b, c) => !cancellationToken.IsCancellationRequested, - RepositoryOperationStarting = (a) => !cancellationToken.IsCancellationRequested, - BranchName = initialBranch, - CredentialsProvider = (a, b, c) => username != null ? (Credentials)new UsernamePasswordCredentials - { - Username = username, - Password = password - } : new DefaultCredentials() - }); - } - catch (UserCancelledException) { } - cancellationToken.ThrowIfCancellationRequested(); - }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current).ConfigureAwait(false); - } - catch - { + lock (this) + { + if (CloneInProgress) + throw new InvalidOperationException("The repository is already being cloned!"); + CloneInProgress = true; + } + try + { + using (await SemaphoreSlimContext.Lock(semaphore, cancellationToken).ConfigureAwait(false)) + if (!await ioManager.DirectoryExists(".", cancellationToken).ConfigureAwait(false)) try { - await ioManager.DeleteDirectory(".", default).ConfigureAwait(false); + await Task.Factory.StartNew(() => + { + string path = null; + try + { + path = LibGit2Sharp.Repository.Clone(url.ToString(), ioManager.ResolvePath("."), new CloneOptions + { + OnProgress = (a) => !cancellationToken.IsCancellationRequested, + OnTransferProgress = (a) => + { + var percentage = 100 * (((float)a.IndexedObjects + a.ReceivedObjects) / (a.TotalObjects * 2)); + progressReporter((int)percentage); + return !cancellationToken.IsCancellationRequested; + }, + RecurseSubmodules = true, + OnUpdateTips = (a, b, c) => !cancellationToken.IsCancellationRequested, + RepositoryOperationStarting = (a) => !cancellationToken.IsCancellationRequested, + BranchName = initialBranch, + CredentialsProvider = (a, b, c) => username != null ? (Credentials)new UsernamePasswordCredentials + { + Username = username, + Password = password + } : new DefaultCredentials() + }); + } + catch (UserCancelledException) { } + cancellationToken.ThrowIfCancellationRequested(); + }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current).ConfigureAwait(false); } - catch { } - throw; - } - else - return null; + catch + { + try + { + await ioManager.DeleteDirectory(".", default).ConfigureAwait(false); + } + catch { } + throw; + } + else + return null; + } + finally + { + CloneInProgress = false; + } return await LoadRepository(cancellationToken).ConfigureAwait(false); } /// public async Task LoadRepository(CancellationToken cancellationToken) { + lock(this) + if (CloneInProgress) + throw new InvalidOperationException("The repository is being cloned!"); await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); LibGit2Sharp.Repository repo = null; await Task.Factory.StartNew(() => diff --git a/src/Tgstation.Server.Host/Components/Watchdog/SessionController.cs b/src/Tgstation.Server.Host/Components/Watchdog/SessionController.cs index e5b24240d5..26d341dc29 100644 --- a/src/Tgstation.Server.Host/Components/Watchdog/SessionController.cs +++ b/src/Tgstation.Server.Host/Components/Watchdog/SessionController.cs @@ -271,7 +271,7 @@ namespace Tgstation.Server.Host.Components.Watchdog if (portClosed) { reattachInformation.Port = nextPort; - portAssignmentTcs.SetResult(true); + portAssignmentTcs.TrySetResult(true); portAssignmentTcs = null; portClosed = false; } @@ -379,9 +379,12 @@ namespace Tgstation.Server.Host.Components.Watchdog lock (this) { if (portAssignmentTcs != null) + { //someone was trying to change the port before us, ignore them //shouldn't happen anyway, add logging here - portAssignmentTcs.SetResult(false); + logger.LogWarning("Hey uhhh, this shouldn't happen ok? Pls to tell cyberboss. SessionController.SetPort"); + portAssignmentTcs.TrySetResult(false); + } nextPort = port; portAssignmentTcs = new TaskCompletionSource(); toWait = portAssignmentTcs.Task; diff --git a/src/Tgstation.Server.Host/Controllers/AdministrationController.cs b/src/Tgstation.Server.Host/Controllers/AdministrationController.cs index 56215e8a08..9e0925bce1 100644 --- a/src/Tgstation.Server.Host/Controllers/AdministrationController.cs +++ b/src/Tgstation.Server.Host/Controllers/AdministrationController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; using Octokit; using System; using System.Collections.Generic; @@ -9,6 +10,7 @@ using System.Linq; using System.Net; using System.Threading; using System.Threading.Tasks; +using Tgstation.Server.Api; using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Configuration; @@ -22,7 +24,7 @@ namespace Tgstation.Server.Host.Controllers /// /// for /// - [Route(Api.Routes.Administration.Base)] + [Route(Routes.Administration)] public sealed class AdministrationController : ModelController { /// @@ -79,7 +81,7 @@ namespace Tgstation.Server.Host.Controllers { Logger.LogWarning("Exceeded GitHub rate limit!"); var secondsString = Math.Ceiling((exception.Reset - DateTimeOffset.Now).TotalSeconds).ToString(CultureInfo.InvariantCulture); - Response.Headers.Add("Retry-After", new Microsoft.Extensions.Primitives.StringValues { }); + Response.Headers.Add("Retry-After", new StringValues(secondsString)); return StatusCode(RateLimitHttpStatusCode); } diff --git a/src/Tgstation.Server.Host/Controllers/ApiController.cs b/src/Tgstation.Server.Host/Controllers/ApiController.cs index 3ee9405cf6..ec175675a3 100644 --- a/src/Tgstation.Server.Host/Controllers/ApiController.cs +++ b/src/Tgstation.Server.Host/Controllers/ApiController.cs @@ -103,7 +103,7 @@ namespace Tgstation.Server.Host.Controllers //if there's no instance user, do a weird thing and add all the instance roles //we need it so we can get to OnActionExecutionAsync where we can properly decide between BadRequest and Forbid //if user is null that means they got the token with an expired password - var rightInt = authenticationContext.User == null || (RightsHelper.IsInstanceRight(I) && authenticationContext.InstanceUser == null) ? ~0 : authenticationContext.GetRight(I); + var rightInt = authenticationContext.User == null || (RightsHelper.IsInstanceRight(I) && authenticationContext.InstanceUser == null) ? ~0U : authenticationContext.GetRight(I); var rightEnum = RightsHelper.RightToType(I); var right = (Enum)Enum.ToObject(rightEnum, rightInt); foreach (Enum J in Enum.GetValues(rightEnum)) diff --git a/src/Tgstation.Server.Host/Controllers/ByondController.cs b/src/Tgstation.Server.Host/Controllers/ByondController.cs index 0d89f56d12..e685bc7a9a 100644 --- a/src/Tgstation.Server.Host/Controllers/ByondController.cs +++ b/src/Tgstation.Server.Host/Controllers/ByondController.cs @@ -5,6 +5,7 @@ using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Tgstation.Server.Api; using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Components; @@ -17,7 +18,7 @@ namespace Tgstation.Server.Host.Controllers /// /// Controller for managing s /// - [Route("/" + nameof(Byond))] + [Route(Routes.Byond)] public sealed class ByondController : ModelController { /// diff --git a/src/Tgstation.Server.Host/Controllers/ChatController.cs b/src/Tgstation.Server.Host/Controllers/ChatController.cs index 2ac361b428..3b823ea0ad 100644 --- a/src/Tgstation.Server.Host/Controllers/ChatController.cs +++ b/src/Tgstation.Server.Host/Controllers/ChatController.cs @@ -9,6 +9,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using Tgstation.Server.Api; using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Components; @@ -19,10 +20,10 @@ using Z.EntityFramework.Plus; namespace Tgstation.Server.Host.Controllers { /// - /// for managing + /// for managing s /// - [Route("/" + nameof(Components.Chat.Chat))] - public sealed class ChatController : ModelController + [Route(Routes.Chat)] + public sealed class ChatController : ModelController { /// /// The for the @@ -56,8 +57,8 @@ namespace Tgstation.Server.Host.Controllers }; /// - [TgsAuthorize(ChatSettingsRights.Create)] - public override async Task Create([FromBody] Api.Models.ChatSettings model, CancellationToken cancellationToken) + [TgsAuthorize(ChatBotRights.Create)] + public override async Task Create([FromBody] Api.Models.ChatBot model, CancellationToken cancellationToken) { if (model == null) throw new ArgumentNullException(nameof(model)); @@ -87,7 +88,7 @@ namespace Tgstation.Server.Host.Controllers return BadRequest(new ErrorMessage { Message = "One or more of channels aren't formatted correctly for the given provider!" }); //try to update das db first - var dbModel = new Models.ChatSettings + var dbModel = new Models.ChatBot { Name = model.Name, ConnectionString = model.ConnectionString, @@ -97,7 +98,7 @@ namespace Tgstation.Server.Host.Controllers Provider = model.Provider, }; - DatabaseContext.ChatSettings.Add(dbModel); + DatabaseContext.ChatBots.Add(dbModel); try { @@ -122,7 +123,7 @@ namespace Tgstation.Server.Host.Controllers catch { //undo the add - DatabaseContext.ChatSettings.Remove(dbModel); + DatabaseContext.ChatBots.Remove(dbModel); await DatabaseContext.Save(default).ConfigureAwait(false); throw; } @@ -135,24 +136,24 @@ namespace Tgstation.Server.Host.Controllers } /// - [TgsAuthorize(ChatSettingsRights.Delete)] + [TgsAuthorize(ChatBotRights.Delete)] public override async Task Delete(long id, CancellationToken cancellationToken) { var instance = instanceManager.GetInstance(Instance); - await Task.WhenAll(instance.Chat.DeleteConnection(id, cancellationToken), DatabaseContext.ChatSettings.Where(x => x.Id == id).DeleteAsync(cancellationToken)).ConfigureAwait(false); + await Task.WhenAll(instance.Chat.DeleteConnection(id, cancellationToken), DatabaseContext.ChatBots.Where(x => x.Id == id).DeleteAsync(cancellationToken)).ConfigureAwait(false); return Ok(); } /// - [TgsAuthorize(ChatSettingsRights.Read)] + [TgsAuthorize(ChatBotRights.Read)] public override async Task List(CancellationToken cancellationToken) { - var query = DatabaseContext.ChatSettings.Where(x => x.InstanceId == Instance.Id).Include(x => x.Channels); + var query = DatabaseContext.ChatBots.Where(x => x.InstanceId == Instance.Id).Include(x => x.Channels); var results = await query.ToListAsync(cancellationToken).ConfigureAwait(false); - var connectionStrings = (AuthenticationContext.GetRight(RightsType.ChatSettings) & (int)ChatSettingsRights.ReadConnectionString) != 0; + var connectionStrings = (AuthenticationContext.GetRight(RightsType.ChatBots) & (int)ChatBotRights.ReadConnectionString) != 0; if (!connectionStrings) foreach (var I in results) @@ -162,16 +163,16 @@ namespace Tgstation.Server.Host.Controllers } /// - [TgsAuthorize(ChatSettingsRights.Read)] + [TgsAuthorize(ChatBotRights.Read)] public override async Task GetId(long id, CancellationToken cancellationToken) { - var query = DatabaseContext.ChatSettings.Where(x => x.Id == id).Include(x => x.Channels); + var query = DatabaseContext.ChatBots.Where(x => x.Id == id).Include(x => x.Channels); var results = await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); if (results == default) return NotFound(); - var connectionStrings = (AuthenticationContext.GetRight(RightsType.ChatSettings) & (int)ChatSettingsRights.ReadConnectionString) != 0; + var connectionStrings = (AuthenticationContext.GetRight(RightsType.ChatBots) & (int)ChatBotRights.ReadConnectionString) != 0; if (!connectionStrings) results.ConnectionString = null; @@ -180,24 +181,24 @@ namespace Tgstation.Server.Host.Controllers } /// - [TgsAuthorize(ChatSettingsRights.WriteChannels | ChatSettingsRights.WriteConnectionString | ChatSettingsRights.WriteEnabled | ChatSettingsRights.WriteName | ChatSettingsRights.WriteProvider)] - public override async Task Update([FromBody] Api.Models.ChatSettings model, CancellationToken cancellationToken) + [TgsAuthorize(ChatBotRights.WriteChannels | ChatBotRights.WriteConnectionString | ChatBotRights.WriteEnabled | ChatBotRights.WriteName | ChatBotRights.WriteProvider)] + public override async Task Update([FromBody] Api.Models.ChatBot model, CancellationToken cancellationToken) { if (model == null) throw new ArgumentNullException(nameof(model)); - var query = DatabaseContext.ChatSettings.Where(x => x.InstanceId == Instance.Id && x.Id == model.Id).Include(x => x.Channels); + var query = DatabaseContext.ChatBots.Where(x => x.InstanceId == Instance.Id && x.Id == model.Id).Include(x => x.Channels); var current = await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); if (current == default) return StatusCode((int)HttpStatusCode.Gone); - var userRights = (ChatSettingsRights)AuthenticationContext.GetRight(RightsType.ChatSettings); + var userRights = (ChatBotRights)AuthenticationContext.GetRight(RightsType.ChatBots); bool anySettingsModified = false; - bool CheckModified(Expression> expression, ChatSettingsRights requiredRight) + bool CheckModified(Expression> expression, ChatBotRights requiredRight) { var memberSelectorExpression = (MemberExpression)expression.Body; var property = (PropertyInfo)memberSelectorExpression.Member; @@ -213,11 +214,11 @@ namespace Tgstation.Server.Host.Controllers return false; }; - if (CheckModified(x => x.ConnectionString, ChatSettingsRights.WriteConnectionString) - || CheckModified(x => x.Enabled, ChatSettingsRights.WriteEnabled) - || CheckModified(x => x.Name, ChatSettingsRights.WriteName) - || CheckModified(x => x.Provider, ChatSettingsRights.WriteProvider) - || (model.Channels != null && !userRights.HasFlag(ChatSettingsRights.WriteChannels))) + if (CheckModified(x => x.ConnectionString, ChatBotRights.WriteConnectionString) + || CheckModified(x => x.Enabled, ChatBotRights.WriteEnabled) + || CheckModified(x => x.Name, ChatBotRights.WriteName) + || CheckModified(x => x.Provider, ChatBotRights.WriteProvider) + || (model.Channels != null && !userRights.HasFlag(ChatBotRights.WriteChannels))) return Forbid(); if (model.Channels != null) @@ -239,9 +240,9 @@ namespace Tgstation.Server.Host.Controllers if (model.Channels != null || anySettingsModified) await chat.ChangeChannels(current.Id, current.Channels, cancellationToken).ConfigureAwait(false); - if (userRights.HasFlag(ChatSettingsRights.Read)) + if (userRights.HasFlag(ChatBotRights.Read)) { - if (!userRights.HasFlag(ChatSettingsRights.ReadConnectionString)) + if (!userRights.HasFlag(ChatBotRights.ReadConnectionString)) current.ConnectionString = null; return Json(current.ToApi()); } diff --git a/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs b/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs index 3920cbb321..ceac94794d 100644 --- a/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs +++ b/src/Tgstation.Server.Host/Controllers/ConfigurationController.cs @@ -4,6 +4,7 @@ using System; using System.Net; using System.Threading; using System.Threading.Tasks; +using Tgstation.Server.Api; using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Components; @@ -15,7 +16,7 @@ namespace Tgstation.Server.Host.Controllers /// /// The for s /// - [Route("/" + nameof(Configuration))] + [Route(Routes.Configuration)] public sealed class ConfigurationController : ModelController { /// diff --git a/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs b/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs index 71c26e546d..dbdee8146b 100644 --- a/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs +++ b/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs @@ -8,6 +8,7 @@ using System.Net; using System.Reflection; using System.Threading; using System.Threading.Tasks; +using Tgstation.Server.Api; using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Components; @@ -21,7 +22,7 @@ namespace Tgstation.Server.Host.Controllers /// /// for managing the /// - [Route("/" + nameof(DreamDaemon))] + [Route(Routes.DreamDaemon)] public sealed class DreamDaemonController : ModelController { /// diff --git a/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs b/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs index 8138100014..50e088ef68 100644 --- a/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs +++ b/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs @@ -6,6 +6,7 @@ using System; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Tgstation.Server.Api; using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Components; using Tgstation.Server.Host.Core; @@ -17,7 +18,7 @@ namespace Tgstation.Server.Host.Controllers /// /// Controller for managing the compiler /// - [Route("/" + nameof(Api.Models.DreamMaker))] + [Route(Routes.DreamMaker)] public sealed class DreamMakerController : ModelController { /// @@ -58,6 +59,29 @@ namespace Tgstation.Server.Host.Controllers }); } + /// + [TgsAuthorize(DreamMakerRights.List)] + public override async Task GetId(long id, CancellationToken cancellationToken) + { + var compileJob = await DatabaseContext.CompileJobs + .Where(x => x.Id == id && x.Job.Instance.Id == Instance.Id) + .Include(x => x.Job).ThenInclude(x => x.StartedBy) + .Include(x => x.RevisionInformation).ThenInclude(x => x.PrimaryTestMerge) + .Include(x => x.RevisionInformation).ThenInclude(x => x.ActiveTestMerges).ThenInclude(x => x.TestMerge) + .FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + if (compileJob == default) + return NotFound(); + return Json(compileJob.ToApi()); + } + + /// + [TgsAuthorize(DreamMakerRights.List)] + public override async Task List(CancellationToken cancellationToken) + { + var compileJobs = await DatabaseContext.CompileJobs.Where(x => x.Job.Instance.Id == Instance.Id).ToListAsync(cancellationToken).ConfigureAwait(false); + return Json(compileJobs.Select(x => x.ToApi())); + } + /// [TgsAuthorize(DreamMakerRights.Compile)] public override async Task Create([FromBody] Api.Models.DreamMaker model, CancellationToken cancellationToken) @@ -75,15 +99,33 @@ namespace Tgstation.Server.Host.Controllers } /// - [TgsAuthorize(DreamMakerRights.SetDme)] + [TgsAuthorize(DreamMakerRights.SetDme | DreamMakerRights.SetApiValidationPort)] public override async Task Update([FromBody] Api.Models.DreamMaker model, CancellationToken cancellationToken) { var hostModel = new DreamMakerSettings { InstanceId = Instance.Id }; + DatabaseContext.DreamMakerSettings.Attach(hostModel); - hostModel.ProjectName = model.ProjectName; + + if (model.ProjectName != null) + { + if (!AuthenticationContext.InstanceUser.DreamMakerRights.Value.HasFlag(DreamMakerRights.SetDme)) + return Forbid(); + if (model.ProjectName.Length == 0) + hostModel.ProjectName = null; + else + hostModel.ProjectName = model.ProjectName; + } + + if (model.ApiValidationPort.HasValue) + { + if (!AuthenticationContext.InstanceUser.DreamMakerRights.Value.HasFlag(DreamMakerRights.SetApiValidationPort)) + return Forbid(); + hostModel.ApiValidationPort = model.ApiValidationPort; + } + await DatabaseContext.Save(cancellationToken).ConfigureAwait(false); return await Read(cancellationToken).ConfigureAwait(false); } @@ -104,8 +146,8 @@ namespace Tgstation.Server.Host.Controllers var ddSettingsTask = databaseContext.DreamDaemonSettings.Where(x => x.InstanceId == instanceModel.Id).Select(x => new DreamDaemonSettings{ StartupTimeout = x.StartupTimeout, SecurityLevel = x.SecurityLevel - }).FirstOrDefaultAsync(cancellationToken); - var projectName = await databaseContext.DreamMakerSettings.Where(x => x.InstanceId == instanceModel.Id).Select(x => x.ProjectName).FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + }).FirstAsync(cancellationToken); + var dreamMakerSettings = await databaseContext.DreamMakerSettings.Where(x => x.InstanceId == instanceModel.Id).FirstAsync(cancellationToken).ConfigureAwait(false); var ddSettings = await ddSettingsTask.ConfigureAwait(false); var instance = instanceManager.GetInstance(instanceModel); @@ -136,7 +178,7 @@ namespace Tgstation.Server.Host.Controllers databaseContext.Instances.Attach(revInfo.Instance); } - compileJob = await instance.DreamMaker.Compile(revInfo, projectName, ddSettings.SecurityLevel.Value, ddSettings.StartupTimeout.Value, repo, cancellationToken).ConfigureAwait(false); + compileJob = await instance.DreamMaker.Compile(revInfo, dreamMakerSettings, ddSettings.SecurityLevel.Value, ddSettings.StartupTimeout.Value, repo, cancellationToken).ConfigureAwait(false); } if (compileJob.DMApiValidated != true) diff --git a/src/Tgstation.Server.Host/Controllers/HomeController.cs b/src/Tgstation.Server.Host/Controllers/HomeController.cs index 0718e569a4..e570df45b6 100644 --- a/src/Tgstation.Server.Host/Controllers/HomeController.cs +++ b/src/Tgstation.Server.Host/Controllers/HomeController.cs @@ -15,7 +15,7 @@ namespace Tgstation.Server.Host.Controllers /// /// Main for the /// - [Route("/")] + [Route(Routes.Root)] public sealed class HomeController : ApiController { /// @@ -124,9 +124,9 @@ namespace Tgstation.Server.Host.Controllers if (!user.Enabled.Value) return Forbid(); - var token = tokenFactory.CreateToken(user, out var expiry); + var token = tokenFactory.CreateToken(user); if (identity != null) - identityCache.CacheSystemIdentity(user, identity, expiry.AddMinutes(1)); //expire the identity slightly after the auth token in case of lag + identityCache.CacheSystemIdentity(user, identity, token.ExpiresAt.Value.AddMinutes(1)); //expire the identity slightly after the auth token in case of lag Logger.LogDebug("Successfully logged in user {0}!", user.Id); diff --git a/src/Tgstation.Server.Host/Controllers/InstanceController.cs b/src/Tgstation.Server.Host/Controllers/InstanceController.cs index 503e6afb1f..bd0b494ce7 100644 --- a/src/Tgstation.Server.Host/Controllers/InstanceController.cs +++ b/src/Tgstation.Server.Host/Controllers/InstanceController.cs @@ -12,6 +12,7 @@ using System.Reflection; using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; +using Tgstation.Server.Api; using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Components; @@ -25,7 +26,7 @@ namespace Tgstation.Server.Host.Controllers /// /// Controller for managing s /// - [Route("/Instance")] + [Route(Routes.InstanceManager)] public sealed class InstanceController : ModelController { /// @@ -80,13 +81,13 @@ namespace Tgstation.Server.Host.Controllers Models.InstanceUser InstanceAdminUser() => new Models.InstanceUser { - ByondRights = (ByondRights)~0, - ChatSettingsRights = (ChatSettingsRights)~0, - ConfigurationRights = (ConfigurationRights)~0, - DreamDaemonRights = (DreamDaemonRights)~0, - DreamMakerRights = (DreamMakerRights)~0, - RepositoryRights = (RepositoryRights)~0, - InstanceUserRights = (InstanceUserRights)~0, + ByondRights = (ByondRights)~0U, + ChatBotRights = (ChatBotRights)~0U, + ConfigurationRights = (ConfigurationRights)~0U, + DreamDaemonRights = (DreamDaemonRights)~0U, + DreamMakerRights = (DreamMakerRights)~0U, + RepositoryRights = (RepositoryRights)~0U, + InstanceUserRights = (InstanceUserRights)~0U, UserId = AuthenticationContext.User.Id }; @@ -122,7 +123,10 @@ namespace Tgstation.Server.Host.Controllers SoftShutdown = false, StartupTimeout = 20 }, - DreamMakerSettings = new DreamMakerSettings(), + DreamMakerSettings = new DreamMakerSettings + { + ApiValidationPort = 1339 + }, Name = model.Name, Online = false, Path = model.Path, diff --git a/src/Tgstation.Server.Host/Controllers/InstanceUserController.cs b/src/Tgstation.Server.Host/Controllers/InstanceUserController.cs index 0b4bbcc25a..fc3ef16c87 100644 --- a/src/Tgstation.Server.Host/Controllers/InstanceUserController.cs +++ b/src/Tgstation.Server.Host/Controllers/InstanceUserController.cs @@ -1,5 +1,4 @@ using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using System; @@ -7,6 +6,7 @@ using System.Linq; using System.Net; using System.Threading; using System.Threading.Tasks; +using Tgstation.Server.Api; using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Models; @@ -18,7 +18,7 @@ namespace Tgstation.Server.Host.Controllers /// /// For managing s /// - [Route("/" + nameof(Models.InstanceUser))] + [Route(Routes.InstanceUser)] public sealed class InstanceUserController : ModelController { /// @@ -57,7 +57,7 @@ namespace Tgstation.Server.Host.Controllers var dbUser = new Models.InstanceUser { ByondRights = model.ByondRights ?? ByondRights.None, - ChatSettingsRights = model.ChatSettingsRights ?? ChatSettingsRights.None, + ChatBotRights = model.ChatBotRights ?? ChatBotRights.None, ConfigurationRights = model.ConfigurationRights ?? ConfigurationRights.None, DreamDaemonRights = model.DreamDaemonRights ?? DreamDaemonRights.None, DreamMakerRights = model.DreamMakerRights ?? DreamMakerRights.None, @@ -93,7 +93,7 @@ namespace Tgstation.Server.Host.Controllers return StatusCode((int)HttpStatusCode.Gone); originalUser.ByondRights = model.ByondRights ?? originalUser.ByondRights; - originalUser.ChatSettingsRights = model.ChatSettingsRights ?? originalUser.ChatSettingsRights; + originalUser.ChatBotRights = model.ChatBotRights ?? originalUser.ChatBotRights; originalUser.ConfigurationRights = model.ConfigurationRights ?? originalUser.ConfigurationRights; originalUser.DreamDaemonRights = model.DreamDaemonRights ?? originalUser.DreamDaemonRights; originalUser.DreamMakerRights = model.DreamMakerRights ?? originalUser.DreamMakerRights; diff --git a/src/Tgstation.Server.Host/Controllers/JobController.cs b/src/Tgstation.Server.Host/Controllers/JobController.cs index b946cbe94c..b48033ef4e 100644 --- a/src/Tgstation.Server.Host/Controllers/JobController.cs +++ b/src/Tgstation.Server.Host/Controllers/JobController.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Net; using System.Threading; using System.Threading.Tasks; +using Tgstation.Server.Api; using Tgstation.Server.Host.Core; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.Security; @@ -15,7 +16,7 @@ namespace Tgstation.Server.Host.Controllers /// /// for s /// - [Route("/" + nameof(Job))] + [Route(Routes.Jobs)] public sealed class JobController : ModelController { /// diff --git a/src/Tgstation.Server.Host/Controllers/RepositoryController.cs b/src/Tgstation.Server.Host/Controllers/RepositoryController.cs index b170498ca5..fd453bf107 100644 --- a/src/Tgstation.Server.Host/Controllers/RepositoryController.cs +++ b/src/Tgstation.Server.Host/Controllers/RepositoryController.cs @@ -11,6 +11,7 @@ using System.Net; using System.Reflection; using System.Threading; using System.Threading.Tasks; +using Tgstation.Server.Api; using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Components; @@ -23,7 +24,7 @@ namespace Tgstation.Server.Host.Controllers /// /// Controller for managing the s /// - [Route("/" + nameof(Repository))] + [Route(Routes.Repository)] public sealed class RepositoryController : ModelController { /// @@ -63,7 +64,7 @@ namespace Tgstation.Server.Host.Controllers IQueryable queryTarget = databaseContext.RevisionInformations; - var revisionInfo = await databaseContext.RevisionInformations.Where(x => x.CommitSha == repoSha && x.Instance.Id == instance.Id) + var revisionInfo = await databaseContext.RevisionInformations.Where(x => x.CommitSha == repoSha && x.Instance.Id == instance.Id) .Include(x => x.CompileJobs) .Include(x => x.ActiveTestMerges).ThenInclude(x => x.TestMerge) //minimal info, they can query the rest if they're allowed .FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); //search every rev info because LOL SHA COLLISIONS @@ -138,6 +139,9 @@ namespace Tgstation.Server.Host.Controllers var repoManager = instanceManager.GetInstance(Instance).RepositoryManager; + if (repoManager.CloneInProgress) + return Conflict(); + using (var repo = await repoManager.LoadRepository(cancellationToken).ConfigureAwait(false)) { if (repo != null) @@ -243,9 +247,12 @@ namespace Tgstation.Server.Host.Controllers if (model.Origin != null) return BadRequest(new ErrorMessage { Message = "origin cannot be modified without deleting the repository!" }); - if(model.NewTestMerges.Any(x => !x.Number.HasValue)) + if(model.NewTestMerges?.Any(x => !x.Number.HasValue) == true) return BadRequest(new ErrorMessage { Message = "All new test merges must provide a number!" }); + if(model.NewTestMerges?.Any(x => model.NewTestMerges.Any(y => x != y && x.Number == y.Number)) == true) + return BadRequest(new ErrorMessage { Message = "Cannot test merge the same PR twice in one job!" }); + var newTestMerges = model.NewTestMerges != null && model.NewTestMerges.Count > 0; var userRights = (RepositoryRights)AuthenticationContext.GetRight(RightsType.Repository); if (newTestMerges && !userRights.HasFlag(RepositoryRights.MergePullRequest)) @@ -282,7 +289,7 @@ namespace Tgstation.Server.Host.Controllers || (model.UpdateFromOrigin == true && !userRights.HasFlag(RepositoryRights.UpdateBranch))) return Forbid(); - if (currentModel.AccessToken.Length == 0 && currentModel.AccessUser.Length == 0) + if (currentModel.AccessToken?.Length == 0 && currentModel.AccessUser?.Length == 0) { //setting an empty string clears everything currentModel.AccessUser = null; @@ -359,7 +366,10 @@ namespace Tgstation.Server.Host.Controllers throw new InvalidOperationException("Merge conflict occurred during origin update!"); await UpdateRevInfo().ConfigureAwait(false); if (fastForward.Value) + { lastRevisionInfo.OriginCommitSha = repo.Head; + await repo.Sychronize(currentModel.AccessUser, currentModel.AccessToken, true, ct).ConfigureAwait(false); + } } } @@ -385,50 +395,94 @@ namespace Tgstation.Server.Host.Controllers lastRevisionInfo.OriginCommitSha = repo.Head; } } - + + Dictionary prMap = null; //test merging if (newTestMerges) { - //optimization: if we've already merged these exact same commits in this fashion before, just find the rev info for it and check it out - Models.RevisionInformation revInfoWereLookingFor = null; - if(lastRevisionInfo.OriginCommitSha == lastRevisionInfo.CommitSha) - { - foreach (var I in model.NewTestMerges) - //normalize the shas to lowercase ala libgit2 -#pragma warning disable CA1308 // Normalize strings to uppercase - I.PullRequestRevision = I.PullRequestRevision?.ToLowerInvariant(); -#pragma warning restore CA1308 // Normalize strings to uppercase + //bit of sanitization + foreach (var I in model.NewTestMerges.Where(x => String.IsNullOrWhiteSpace(x.PullRequestRevision))) + I.PullRequestRevision = null; - revInfoWereLookingFor = await databaseContext.RevisionInformations - .Where(x => x.OriginCommitSha == lastRevisionInfo.OriginCommitSha && x.ActiveTestMerges.Count == model.NewTestMerges.Count) - //split here cause this bit probably has to be done locally - .Where(x => x.ActiveTestMerges.Select(y => y.TestMerge) - .All(y => model.NewTestMerges.Any(z => y.Number == z.Number && y.PullRequestRevision.StartsWith(z.PullRequestRevision, StringComparison.Ordinal)))) - .Include(x => x.ActiveTestMerges).ThenInclude(x => x.TestMerge) - .FirstOrDefaultAsync(ct).ConfigureAwait(false); + var gitHubClient = currentModel.AccessToken != null ? gitHubClientFactory.CreateClient(currentModel.AccessToken) : gitHubClientFactory.CreateClient(); + + var repoOwner = repo.GitHubOwner; + var repoName = repo.GitHubRepoName; + + Models.RevisionInformation revInfoWereLookingFor = null; + //optimization: if we've already merged these exact same commits in this fashion before, just find the rev info for it and check it out + if (lastRevisionInfo.OriginCommitSha == lastRevisionInfo.CommitSha) + { + //In order for this to work though we need the shas of all the commits + if (model.NewTestMerges.Any(x => x.PullRequestRevision == null)) + prMap = new Dictionary(); + + bool cantSearch = false; + foreach (var I in model.NewTestMerges) + { + if (I.PullRequestRevision != null) + //normalize the shas to lowercase ala libgit2 +#pragma warning disable CA1308 // Normalize strings to uppercase + I.PullRequestRevision = I.PullRequestRevision?.ToLowerInvariant(); +#pragma warning restore CA1308 // Normalize strings to uppercase + else + //retrieve the latest sha + try + { + var pr = await gitHubClient.PullRequest.Get(repoOwner, repoName, I.Number.Value).ConfigureAwait(false); + prMap.Add(I.Number.Value, pr); + I.PullRequestRevision = pr.Head.Sha; + } + catch + { + cantSearch = true; + break; + } + } + + if (!cantSearch) + { + var dbPull = await databaseContext.RevisionInformations + .Where(x => x.Instance.Id == Instance.Id + && x.OriginCommitSha == lastRevisionInfo.OriginCommitSha + && x.ActiveTestMerges.Count == model.NewTestMerges.Count) + .Include(x => x.ActiveTestMerges) + .ThenInclude(x => x.TestMerge) + .ToListAsync(cancellationToken).ConfigureAwait(false); + //split here cause this bit has to be done locally + revInfoWereLookingFor = dbPull + .Where(x => x.ActiveTestMerges.Select(y => y.TestMerge) + .All(y => model.NewTestMerges.Any(z => + y.Number == z.Number + && y.PullRequestRevision.StartsWith(z.PullRequestRevision, StringComparison.Ordinal) + && y.Comment?.Trim().ToUpperInvariant() == z.Comment?.Trim().ToUpperInvariant() || z.Comment == null))) + .FirstOrDefault(); + } } - if (revInfoWereLookingFor != null) + { + //goteem await repo.ResetToSha(revInfoWereLookingFor.CommitSha, cancellationToken).ConfigureAwait(false); + lastRevisionInfo = revInfoWereLookingFor; + } else { - var gitHubClient = currentModel.AccessToken != null ? gitHubClientFactory.CreateClient(currentModel.AccessToken) : gitHubClientFactory.CreateClient(); var contextUser = new Models.User { Id = AuthenticationContext.User.Id }; databaseContext.Users.Attach(contextUser); - var repoOwner = repo.GitHubOwner; - var repoName = repo.GitHubRepoName; foreach (var I in model.NewTestMerges) { Octokit.PullRequest pr = null; string errorMessage = null; try { - pr = await gitHubClient.PullRequest.Get(repoOwner, repoName, I.Number.Value).ConfigureAwait(false); + //load from cache if possible + if (prMap == null || !prMap.TryGetValue(I.Number.Value, out pr)) + pr = await gitHubClient.PullRequest.Get(repoOwner, repoName, I.Number.Value).ConfigureAwait(false); } catch (Octokit.RateLimitExceededException) { @@ -477,9 +531,8 @@ namespace Tgstation.Server.Host.Controllers } } } - - //never synchronize with test merges - if (startSha != repo.Head && lastRevisionInfo.ActiveTestMerges.Count == 0) + + if (startSha != repo.Head) { await repo.Sychronize(currentModel.AccessUser, currentModel.AccessToken, false, ct).ConfigureAwait(false); await UpdateRevInfo().ConfigureAwait(false); diff --git a/src/Tgstation.Server.Host/Controllers/TgsAuthorizeAttribute.cs b/src/Tgstation.Server.Host/Controllers/TgsAuthorizeAttribute.cs index fb2f5377f3..d6eb1b25b6 100644 --- a/src/Tgstation.Server.Host/Controllers/TgsAuthorizeAttribute.cs +++ b/src/Tgstation.Server.Host/Controllers/TgsAuthorizeAttribute.cs @@ -52,10 +52,10 @@ namespace Tgstation.Server.Host.Controllers public TgsAuthorizeAttribute(DreamDaemonRights requiredRights) => Roles = RightsHelper.RoleNames(requiredRights); /// - /// Construct a for + /// Construct a for /// /// The rights required - public TgsAuthorizeAttribute(ChatSettingsRights requiredRights) => Roles = RightsHelper.RoleNames(requiredRights); + public TgsAuthorizeAttribute(ChatBotRights requiredRights) => Roles = RightsHelper.RoleNames(requiredRights); /// /// Construct a for diff --git a/src/Tgstation.Server.Host/Controllers/UserController.cs b/src/Tgstation.Server.Host/Controllers/UserController.cs index 3e4833ad79..be981aea6b 100644 --- a/src/Tgstation.Server.Host/Controllers/UserController.cs +++ b/src/Tgstation.Server.Host/Controllers/UserController.cs @@ -9,6 +9,7 @@ using System.Linq; using System.Net; using System.Threading; using System.Threading.Tasks; +using Tgstation.Server.Api; using Tgstation.Server.Api.Models; using Tgstation.Server.Api.Rights; using Tgstation.Server.Host.Configuration; @@ -20,7 +21,7 @@ namespace Tgstation.Server.Host.Controllers /// /// For managing s /// - [Route("/" + nameof(Models.User))] + [Route(Routes.User)] public sealed class UserController : ModelController { /// diff --git a/src/Tgstation.Server.Host/Models/ChatSettings.cs b/src/Tgstation.Server.Host/Models/ChatBot.cs similarity index 72% rename from src/Tgstation.Server.Host/Models/ChatSettings.cs rename to src/Tgstation.Server.Host/Models/ChatBot.cs index 816139e347..807a31f908 100644 --- a/src/Tgstation.Server.Host/Models/ChatSettings.cs +++ b/src/Tgstation.Server.Host/Models/ChatBot.cs @@ -1,12 +1,11 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; -using Tgstation.Server.Api.Models; namespace Tgstation.Server.Host.Models { /// - public sealed class ChatSettings : Api.Models.Internal.ChatSettings, IApiConvertable + public sealed class ChatBot : Api.Models.Internal.ChatBot, IApiConvertable { /// /// The @@ -20,12 +19,12 @@ namespace Tgstation.Server.Host.Models public Instance Instance { get; set; } /// - /// See + /// See /// public List Channels { get; set; } /// - public Api.Models.ChatSettings ToApi() => new Api.Models.ChatSettings + public Api.Models.ChatBot ToApi() => new Api.Models.ChatBot { Channels = Channels.Select(x => x.ToApi()).ToList(), ConnectionString = ConnectionString, diff --git a/src/Tgstation.Server.Host/Models/ChatChannel.cs b/src/Tgstation.Server.Host/Models/ChatChannel.cs index 09fcf41df6..c47d117d74 100644 --- a/src/Tgstation.Server.Host/Models/ChatChannel.cs +++ b/src/Tgstation.Server.Host/Models/ChatChannel.cs @@ -11,14 +11,14 @@ namespace Tgstation.Server.Host.Models public long Id { get; set; } /// - /// The + /// The /// public long ChatSettingsId { get; set; } /// - /// The + /// The /// - public ChatSettings ChatSettings { get; set; } + public ChatBot ChatSettings { get; set; } /// public Api.Models.ChatChannel ToApi() => new Api.Models.ChatChannel diff --git a/src/Tgstation.Server.Host/Models/DatabaseContext.cs b/src/Tgstation.Server.Host/Models/DatabaseContext.cs index 1b783e23a2..f4109b737a 100644 --- a/src/Tgstation.Server.Host/Models/DatabaseContext.cs +++ b/src/Tgstation.Server.Host/Models/DatabaseContext.cs @@ -28,7 +28,7 @@ namespace Tgstation.Server.Host.Models public DbSet DreamMakerSettings { get; set; } /// - public DbSet ChatSettings { get; set; } + public DbSet ChatBots { get; set; } /// public DbSet DreamDaemonSettings { get; set; } @@ -122,7 +122,7 @@ namespace Tgstation.Server.Host.Models chatChannel.HasIndex(x => new { x.ChatSettingsId, x.DiscordChannelId }).IsUnique(); chatChannel.HasOne(x => x.ChatSettings).WithMany(x => x.Channels).HasForeignKey(x => x.ChatSettingsId).OnDelete(DeleteBehavior.Cascade); - modelBuilder.Entity().HasIndex(x => x.Name).IsUnique(); + modelBuilder.Entity().HasIndex(x => x.Name).IsUnique(); var instanceModel = modelBuilder.Entity(); instanceModel.HasIndex(x => x.Path).IsUnique(); diff --git a/src/Tgstation.Server.Host/Models/DatabaseSeeder.cs b/src/Tgstation.Server.Host/Models/DatabaseSeeder.cs index acaf0e1f53..4603c3e09d 100644 --- a/src/Tgstation.Server.Host/Models/DatabaseSeeder.cs +++ b/src/Tgstation.Server.Host/Models/DatabaseSeeder.cs @@ -40,9 +40,9 @@ namespace Tgstation.Server.Host.Models { var admin = new User { - AdministrationRights = (AdministrationRights)~0, + AdministrationRights = (AdministrationRights)~0U, CreatedAt = DateTimeOffset.Now, - InstanceManagerRights = (InstanceManagerRights)~0, + InstanceManagerRights = (InstanceManagerRights)~0U, Name = AdminName, CanonicalName = AdminName.ToUpperInvariant(), Enabled = true, diff --git a/src/Tgstation.Server.Host/Models/IDatabaseContext.cs b/src/Tgstation.Server.Host/Models/IDatabaseContext.cs index ea02c85426..8fdb8aad74 100644 --- a/src/Tgstation.Server.Host/Models/IDatabaseContext.cs +++ b/src/Tgstation.Server.Host/Models/IDatabaseContext.cs @@ -50,9 +50,9 @@ namespace Tgstation.Server.Host.Models DbSet DreamDaemonSettings { get; set; } /// - /// The in the + /// The in the /// - DbSet ChatSettings { get; set; } + DbSet ChatBots { get; set; } /// /// The in the diff --git a/src/Tgstation.Server.Host/Models/Instance.cs b/src/Tgstation.Server.Host/Models/Instance.cs index 10b2eb9ece..4c22fd55b8 100644 --- a/src/Tgstation.Server.Host/Models/Instance.cs +++ b/src/Tgstation.Server.Host/Models/Instance.cs @@ -34,9 +34,9 @@ namespace Tgstation.Server.Host.Models public List InstanceUsers { get; set; } /// - /// The s for the + /// The s for the /// - public List ChatSettings { get; set; } + public List ChatSettings { get; set; } /// /// The s in the diff --git a/src/Tgstation.Server.Host/Models/InstanceUser.cs b/src/Tgstation.Server.Host/Models/InstanceUser.cs index 152738ef3a..925915f278 100644 --- a/src/Tgstation.Server.Host/Models/InstanceUser.cs +++ b/src/Tgstation.Server.Host/Models/InstanceUser.cs @@ -26,7 +26,7 @@ namespace Tgstation.Server.Host.Models /// If the has any instance rights /// public bool AnyRights => ByondRights != Api.Rights.ByondRights.None || - ChatSettingsRights != Api.Rights.ChatSettingsRights.None || + ChatBotRights != Api.Rights.ChatBotRights.None || ConfigurationRights != Api.Rights.ConfigurationRights.None || DreamDaemonRights != Api.Rights.DreamDaemonRights.None || DreamMakerRights != Api.Rights.DreamMakerRights.None || @@ -36,7 +36,7 @@ namespace Tgstation.Server.Host.Models public Api.Models.InstanceUser ToApi() => new Api.Models.InstanceUser { ByondRights = ByondRights, - ChatSettingsRights = ChatSettingsRights, + ChatBotRights = ChatBotRights, ConfigurationRights = ConfigurationRights, DreamDaemonRights = DreamDaemonRights, DreamMakerRights = DreamMakerRights, diff --git a/src/Tgstation.Server.Host/Security/AuthenticationContext.cs b/src/Tgstation.Server.Host/Security/AuthenticationContext.cs index 32ff73c8e3..d98d36c10f 100644 --- a/src/Tgstation.Server.Host/Security/AuthenticationContext.cs +++ b/src/Tgstation.Server.Host/Security/AuthenticationContext.cs @@ -59,7 +59,7 @@ namespace Tgstation.Server.Host.Security public IAuthenticationContext Clone() => new AuthenticationContext(SystemIdentity.Clone(), User, InstanceUser); /// - public int GetRight(RightsType rightsType) + public ulong GetRight(RightsType rightsType) { var isInstance = RightsHelper.IsInstanceRight(rightsType); @@ -81,7 +81,7 @@ namespace Tgstation.Server.Host.Security if (right == null) throw new InvalidOperationException("A user right was null!"); - return (int)right; + return (ulong)right; } } } diff --git a/src/Tgstation.Server.Host/Security/IAuthenticationContext.cs b/src/Tgstation.Server.Host/Security/IAuthenticationContext.cs index 2b1ee41a25..23510b2804 100644 --- a/src/Tgstation.Server.Host/Security/IAuthenticationContext.cs +++ b/src/Tgstation.Server.Host/Security/IAuthenticationContext.cs @@ -24,7 +24,7 @@ namespace Tgstation.Server.Host.Security /// /// The of the right to get /// The value of . Note that if is all based rights will return 0 - int GetRight(RightsType rightsType); + ulong GetRight(RightsType rightsType); /// /// The of if applicable diff --git a/src/Tgstation.Server.Host/Security/ITokenFactory.cs b/src/Tgstation.Server.Host/Security/ITokenFactory.cs index 7f40b2112d..cd67f0d876 100644 --- a/src/Tgstation.Server.Host/Security/ITokenFactory.cs +++ b/src/Tgstation.Server.Host/Security/ITokenFactory.cs @@ -12,8 +12,7 @@ namespace Tgstation.Server.Host.Security /// Create a for a given /// /// The to create the token for. Must have the field available - /// The representing the time the token expires /// A new - Token CreateToken(Models.User user, out DateTimeOffset expiry); + Token CreateToken(Models.User user); } } diff --git a/src/Tgstation.Server.Host/Security/TokenFactory.cs b/src/Tgstation.Server.Host/Security/TokenFactory.cs index e3603937bc..f9eb4d0f85 100644 --- a/src/Tgstation.Server.Host/Security/TokenFactory.cs +++ b/src/Tgstation.Server.Host/Security/TokenFactory.cs @@ -21,12 +21,12 @@ namespace Tgstation.Server.Host.Security public static readonly byte[] TokenSigningKey = CryptographySuite.GetSecureBytes(256); /// - public Token CreateToken(Models.User user, out DateTimeOffset expiry) + public Token CreateToken(Models.User user) { if (user == null) throw new ArgumentNullException(nameof(user)); - expiry = DateTimeOffset.Now.AddMinutes(TokenExpiryMinutes); + var expiry = DateTimeOffset.Now.AddMinutes(TokenExpiryMinutes); var claims = new Claim[] { new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString(CultureInfo.InvariantCulture)), @@ -39,7 +39,7 @@ namespace Tgstation.Server.Host.Security var key = new SymmetricSecurityKey(TokenSigningKey); var token = new JwtSecurityToken(new JwtHeader(new SigningCredentials(key, SecurityAlgorithms.HmacSha256)), new JwtPayload(claims)); - return new Token { Bearer = new JwtSecurityTokenHandler().WriteToken(token) }; + return new Token { Bearer = new JwtSecurityTokenHandler().WriteToken(token), ExpiresAt = expiry }; } } } diff --git a/tests/Tgstation.Server.Client.Tests/TestServerClientFactory.cs b/tests/Tgstation.Server.Client.Tests/TestServerClientFactory.cs deleted file mode 100644 index 981ce7295e..0000000000 --- a/tests/Tgstation.Server.Client.Tests/TestServerClientFactory.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; -using System; -using System.Threading.Tasks; - -namespace Tgstation.Server.Client.Tests -{ - /// - /// Tests for - /// - [TestClass] - public sealed class TestServerClientFactory - { - [TestMethod] - public async Task TestUserConstruction() - { - var factory = new ServerClientFactory(); - await Assert.ThrowsExceptionAsync(() => factory.CreateServerClient(null, null, null, default, default)).ConfigureAwait(false); - } - - [TestMethod] - public async Task TestTokenConstruction() - { - var factory = new ServerClientFactory(); - await Assert.ThrowsExceptionAsync(() => factory.CreateServerClient(null, null, default, default)).ConfigureAwait(false); - } - } -} diff --git a/tests/Tgstation.Server.Host.Tests/Security/TestAuthenticationContext.cs b/tests/Tgstation.Server.Host.Tests/Security/TestAuthenticationContext.cs index 91af8fa215..0c2291bfe1 100644 --- a/tests/Tgstation.Server.Host.Tests/Security/TestAuthenticationContext.cs +++ b/tests/Tgstation.Server.Host.Tests/Security/TestAuthenticationContext.cs @@ -44,8 +44,8 @@ namespace Tgstation.Server.Host.Security.Tests user.AdministrationRights = AdministrationRights.EditUsers; instanceUser.ByondRights = ByondRights.ChangeVersion | ByondRights.ReadActive; - Assert.AreEqual((int)user.AdministrationRights, authContext.GetRight(RightsType.Administration)); - Assert.AreEqual((int)instanceUser.ByondRights, authContext.GetRight(RightsType.Byond)); + Assert.AreEqual((ulong)user.AdministrationRights, authContext.GetRight(RightsType.Administration)); + Assert.AreEqual((ulong)instanceUser.ByondRights, authContext.GetRight(RightsType.Byond)); } } } diff --git a/v4_prototype_TODO.txt b/v4_prototype_TODO.txt index 7333b2c540..934a941146 100644 --- a/v4_prototype_TODO.txt +++ b/v4_prototype_TODO.txt @@ -1,3 +1,5 @@ +Change permission bits to u64 + Verify the byond cache folder location on linux Test watchdog