diff --git a/.gitignore b/.gitignore index 7cf87e782..8baefa9b1 100644 --- a/.gitignore +++ b/.gitignore @@ -238,3 +238,5 @@ launchSettings.json /GameLogServer/log_env **/*.css /Master/master/persistence +/WebfrontCore/wwwroot/fonts +/WebfrontCore/wwwroot/font diff --git a/Application/Application.csproj b/Application/Application.csproj index 1140f03b7..9abb26c63 100644 --- a/Application/Application.csproj +++ b/Application/Application.csproj @@ -24,11 +24,11 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/Application/ApplicationManager.cs b/Application/ApplicationManager.cs index 5d1973eea..4bea75fc5 100644 --- a/Application/ApplicationManager.cs +++ b/Application/ApplicationManager.cs @@ -5,6 +5,7 @@ using IW4MAdmin.Application.RconParsers; using SharedLibraryCore; using SharedLibraryCore.Commands; using SharedLibraryCore.Configuration; +using SharedLibraryCore.Configuration.Validation; using SharedLibraryCore.Database; using SharedLibraryCore.Database.Models; using SharedLibraryCore.Dtos; @@ -277,7 +278,7 @@ namespace IW4MAdmin.Application if (newConfig.Servers == null) { ConfigHandler.Set(newConfig); - newConfig.Servers = new List(); + newConfig.Servers = new ServerConfiguration[1]; do { @@ -292,7 +293,7 @@ namespace IW4MAdmin.Application serverConfig.AddEventParser(parser); } - newConfig.Servers.Add((ServerConfiguration)serverConfig.Generate()); + newConfig.Servers[0] = (ServerConfiguration)serverConfig.Generate(); } while (Utilities.PromptBool(Utilities.CurrentLocalization.LocalizationIndex["SETUP_SERVER_SAVE"])); config = newConfig; @@ -314,6 +315,17 @@ namespace IW4MAdmin.Application await ConfigHandler.Save(); } + var validator = new ApplicationConfigurationValidator(); + var validationResult = validator.Validate(config); + + if (!validationResult.IsValid) + { + throw new ConfigurationException(Utilities.CurrentLocalization.LocalizationIndex["MANAGER_CONFIGURATION_ERROR"]) + { + Errors = validationResult.Errors.Select(_error => _error.ErrorMessage).ToArray() + }; + } + foreach (var serverConfig in config.Servers) { Migration.ConfigurationMigration.ModifyLogPath020919(serverConfig); @@ -336,7 +348,7 @@ namespace IW4MAdmin.Application } } - if (config.Servers.Count == 0) + if (config.Servers.Length == 0) { throw new ServerException("A server configuration in IW4MAdminSettings.json is invalid"); } @@ -584,7 +596,7 @@ namespace IW4MAdmin.Application throw lastException; } - if (successServers != config.Servers.Count) + if (successServers != config.Servers.Length) { if (!Utilities.PromptBool(Utilities.CurrentLocalization.LocalizationIndex["MANAGER_START_WITH_ERRORS"])) { diff --git a/Application/Main.cs b/Application/Main.cs index 0c1b31af5..5bf79a111 100644 --- a/Application/Main.cs +++ b/Application/Main.cs @@ -2,6 +2,7 @@ using IW4MAdmin.Application.Misc; using Microsoft.Extensions.DependencyInjection; using SharedLibraryCore; +using SharedLibraryCore.Exceptions; using SharedLibraryCore.Helpers; using SharedLibraryCore.Interfaces; using System; @@ -96,6 +97,15 @@ namespace IW4MAdmin.Application } Console.WriteLine(e.Message); + + if (e is ConfigurationException cfgE) + { + foreach (string error in cfgE.Errors) + { + Console.WriteLine(error); + } + } + Console.WriteLine(exitMessage); Console.ReadKey(); return; diff --git a/Plugins/AutomessageFeed/AutomessageFeed.csproj b/Plugins/AutomessageFeed/AutomessageFeed.csproj index 6e46fd422..58e8e9437 100644 --- a/Plugins/AutomessageFeed/AutomessageFeed.csproj +++ b/Plugins/AutomessageFeed/AutomessageFeed.csproj @@ -10,7 +10,7 @@ - + diff --git a/Plugins/LiveRadar/LiveRadar.csproj b/Plugins/LiveRadar/LiveRadar.csproj index 292ba9613..2bf620294 100644 --- a/Plugins/LiveRadar/LiveRadar.csproj +++ b/Plugins/LiveRadar/LiveRadar.csproj @@ -16,7 +16,7 @@ - + diff --git a/Plugins/LiveRadar/RadarEvent.cs b/Plugins/LiveRadar/RadarEvent.cs index 056c2bd66..ef166be6f 100644 --- a/Plugins/LiveRadar/RadarEvent.cs +++ b/Plugins/LiveRadar/RadarEvent.cs @@ -45,7 +45,7 @@ namespace LiveRadar var parsedEvent = new RadarEvent() { - Guid = items[0].ConvertGuidToLong(), + Guid = items[0].ConvertGuidToLong(System.Globalization.NumberStyles.HexNumber), Location = Vector3.Parse(items[1]), ViewAngles = Vector3.Parse(items[2]).FixIW4Angles(), Team = items[3], diff --git a/Plugins/Login/Login.csproj b/Plugins/Login/Login.csproj index 1536f3a64..da15e2ed5 100644 --- a/Plugins/Login/Login.csproj +++ b/Plugins/Login/Login.csproj @@ -23,7 +23,7 @@ - + diff --git a/Plugins/ProfanityDeterment/ProfanityDeterment.csproj b/Plugins/ProfanityDeterment/ProfanityDeterment.csproj index 354838c49..50e7aabab 100644 --- a/Plugins/ProfanityDeterment/ProfanityDeterment.csproj +++ b/Plugins/ProfanityDeterment/ProfanityDeterment.csproj @@ -16,7 +16,7 @@ - + diff --git a/Plugins/Stats/Cheat/Detection.cs b/Plugins/Stats/Cheat/Detection.cs index 808198ee1..a66ca7806 100644 --- a/Plugins/Stats/Cheat/Detection.cs +++ b/Plugins/Stats/Cheat/Detection.cs @@ -92,7 +92,6 @@ namespace IW4MAdmin.Plugins.Stats.Cheat HitLocationCount[hit.HitLoc].Count++; HitCount++; - if (!isDamage) { Kills++; @@ -200,12 +199,6 @@ namespace IW4MAdmin.Plugins.Stats.Cheat if (weightedLifetimeAverage > Thresholds.MaxOffset(totalHits) && hitLoc.HitCount > 100) { - //Log.WriteDebug("*** Reached Max Lifetime Average for Angle Difference ***"); - //Log.WriteDebug($"Lifetime Average = {newAverage}"); - //Log.WriteDebug($"Bone = {hitLoc.Location}"); - //Log.WriteDebug($"HitCount = {hitLoc.HitCount}"); - //Log.WriteDebug($"ID = {hit.AttackerId}"); - results.Add(new DetectionPenaltyResult() { ClientPenalty = EFPenalty.PenaltyType.Ban, @@ -437,7 +430,7 @@ namespace IW4MAdmin.Plugins.Stats.Cheat ClientId = ClientStats.ClientId, SessionAngleOffset = AngleDifferenceAverage, RecoilOffset = hitRecoilAverage, - CurrentSessionLength = (int)(DateTime.UtcNow - ConnectionTime).TotalSeconds, + CurrentSessionLength = (int)(DateTime.UtcNow - ConnectionTime).TotalMinutes, CurrentStrain = currentStrain, CurrentViewAngle = new Vector3(hit.ViewAngles.X, hit.ViewAngles.Y, hit.ViewAngles.Z), Hits = HitCount, @@ -453,7 +446,7 @@ namespace IW4MAdmin.Plugins.Stats.Cheat Distance = hit.Distance, SessionScore = ClientStats.SessionScore, HitType = hit.DeathType, - SessionSPM = ClientStats.SessionSPM, + SessionSPM = Math.Round(ClientStats.SessionSPM, 0), StrainAngleBetween = Strain.LastDistance, TimeSinceLastEvent = (int)Strain.LastDeltaTime, WeaponId = hit.Weapon, diff --git a/Plugins/Stats/Models/EFClientStatistics.cs b/Plugins/Stats/Models/EFClientStatistics.cs index 8bcb518f7..c406956c7 100644 --- a/Plugins/Stats/Models/EFClientStatistics.cs +++ b/Plugins/Stats/Models/EFClientStatistics.cs @@ -92,9 +92,13 @@ namespace IW4MAdmin.Plugins.Stats.Models { SessionScores[SessionScores.Count - 1] = value; } + get { - return SessionScores.Sum(); + lock (SessionScores) + { + return SessionScores.Sum(); + } } } [NotMapped] diff --git a/Plugins/Stats/Plugin.cs b/Plugins/Stats/Plugin.cs index b0fa519f1..3cae668f0 100644 --- a/Plugins/Stats/Plugin.cs +++ b/Plugins/Stats/Plugin.cs @@ -22,7 +22,7 @@ namespace IW4MAdmin.Plugins.Stats { public string Name => "Simple Stats"; - public float Version => Assembly.GetExecutingAssembly().GetName().Version.Major + Assembly.GetExecutingAssembly().GetName().Version.Minor / 10.0f; + public float Version => (float)Utilities.GetVersionAsDouble(); public string Author => "RaidMax"; @@ -102,6 +102,11 @@ namespace IW4MAdmin.Plugins.Stats S.Logger.WriteInfo($"End ScriptKill {scriptKillCount}"); #endif } + + else + { + E.Owner.Logger.WriteDebug("Skipping script kill as it is ignored or data in customcallbacks is outdated/missing"); + } break; case GameEvent.EventType.Kill: if (!ShouldIgnoreEvent(E.Origin, E.Target)) @@ -149,6 +154,11 @@ namespace IW4MAdmin.Plugins.Stats S.Logger.WriteInfo($"End ScriptDamage {scriptDamageCount}"); #endif } + + else + { + E.Owner.Logger.WriteDebug("Skipping script damage as it is ignored or data in customcallbacks is outdated/missing"); + } break; } } diff --git a/Plugins/Stats/Stats.csproj b/Plugins/Stats/Stats.csproj index 031f5fd5a..5684a8f6d 100644 --- a/Plugins/Stats/Stats.csproj +++ b/Plugins/Stats/Stats.csproj @@ -16,7 +16,7 @@ - + diff --git a/Plugins/Tests/ManagerFixture.cs b/Plugins/Tests/ManagerFixture.cs index 353ce3f7c..fbe1cb82c 100644 --- a/Plugins/Tests/ManagerFixture.cs +++ b/Plugins/Tests/ManagerFixture.cs @@ -24,23 +24,18 @@ namespace Tests var config = new ApplicationConfiguration { - Servers = new List() + Servers = new[] { new ServerConfiguration() { - AutoMessages = new List(), IPAddress = "127.0.0.1", Password = "test", Port = 28960, - Rules = new List(), RConParserVersion = "test", EventParserVersion = "IW4x (v0.6.0)", ManualLogPath = logFile } }, - AutoMessages = new List(), - GlobalRules = new List(), - Maps = new List(), RConPollRate = int.MaxValue }; diff --git a/Plugins/Web/StatsWeb/StatsWeb.csproj b/Plugins/Web/StatsWeb/StatsWeb.csproj index ca48f2716..c0bb83423 100644 --- a/Plugins/Web/StatsWeb/StatsWeb.csproj +++ b/Plugins/Web/StatsWeb/StatsWeb.csproj @@ -14,7 +14,7 @@ Always - + diff --git a/Plugins/Welcome/Welcome.csproj b/Plugins/Welcome/Welcome.csproj index 82258f0b4..1464736ce 100644 --- a/Plugins/Welcome/Welcome.csproj +++ b/Plugins/Welcome/Welcome.csproj @@ -16,7 +16,7 @@ - + diff --git a/SharedLibraryCore/Commands/NativeCommands.cs b/SharedLibraryCore/Commands/NativeCommands.cs index d51a51011..7c64ae9cf 100644 --- a/SharedLibraryCore/Commands/NativeCommands.cs +++ b/SharedLibraryCore/Commands/NativeCommands.cs @@ -701,8 +701,8 @@ namespace SharedLibraryCore.Commands public override Task ExecuteAsync(GameEvent E) { - if (E.Owner.Manager.GetApplicationSettings().Configuration().GlobalRules?.Count < 1 && - E.Owner.ServerConfig.Rules?.Count < 1) + if (E.Owner.Manager.GetApplicationSettings().Configuration().GlobalRules?.Length < 1 && + E.Owner.ServerConfig.Rules?.Length < 1) { var _ = E.Message.IsBroadcastCommand() ? E.Owner.Broadcast(Utilities.CurrentLocalization.LocalizationIndex["COMMANDS_RULES_NONE"]) : diff --git a/SharedLibraryCore/Configuration/ApplicationConfiguration.cs b/SharedLibraryCore/Configuration/ApplicationConfiguration.cs index 629410d1a..83b049cc0 100644 --- a/SharedLibraryCore/Configuration/ApplicationConfiguration.cs +++ b/SharedLibraryCore/Configuration/ApplicationConfiguration.cs @@ -1,14 +1,12 @@ using SharedLibraryCore.Configuration.Attributes; using SharedLibraryCore.Interfaces; using System; -using System.Collections.Generic; using System.ComponentModel.DataAnnotations; namespace SharedLibraryCore.Configuration { public class ApplicationConfiguration : IBaseConfiguration { - [LocalizedDisplayName("SETUP_ENABLE_WEBFRONT")] [ConfigurationLinked("WebfrontBindUrl", "ManualWebfrontUrl", "WebfrontPrimaryColor", "WebfrontSecondaryColor", "WebfrontCustomBranding")] public bool EnableWebFront { get; set; } @@ -60,7 +58,7 @@ namespace SharedLibraryCore.Configuration [ConfigurationLinked("WebfrontConnectionWhitelist")] public bool EnableWebfrontConnectionWhitelist { get; set; } [LocalizedDisplayName("WEBFRONT_CONFIGURATION_WHITELIST_LIST")] - public List WebfrontConnectionWhitelist { get; set; } + public string[] WebfrontConnectionWhitelist { get; set; } = new string[0]; [LocalizedDisplayName("WEBFRONT_CONFIGURATION_CUSTOM_LOCALE")] [ConfigurationLinked("CustomLocale")] @@ -68,7 +66,6 @@ namespace SharedLibraryCore.Configuration [LocalizedDisplayName("WEBFRONT_CONFIGURATION_CUSTOM_LOCALE")] public string CustomLocale { get; set; } - [ConfigurationOptional] [LocalizedDisplayName("WEBFRONT_CONFIGURATION_DB_PROVIDER")] public string DatabaseProvider { get; set; } = "sqlite"; [ConfigurationOptional] @@ -78,26 +75,25 @@ namespace SharedLibraryCore.Configuration public int RConPollRate { get; set; } = 5000; [LocalizedDisplayName("WEBFRONT_CONFIGURATION_MAX_TB")] public TimeSpan MaximumTempBanTime { get; set; } = new TimeSpan(24 * 30, 0, 0); - [LocalizedDisplayName("SETUP_ENABLE_COLOR_CODES")] + [LocalizedDisplayName("WEBFRONT_CONFIGURATION_ENABLE_COLOR_CODES")] public bool EnableColorCodes { get; set; } [LocalizedDisplayName("WEBFRONT_CONFIGURATION_AUTOMESSAGE_PERIOD")] public int AutoMessagePeriod { get; set; } [LocalizedDisplayName("WEBFRONT_CONFIGURATION_AUTOMESSAGES")] - public List AutoMessages { get; set; } + public string[] AutoMessages { get; set; } = new string[0]; [LocalizedDisplayName("WEBFRONT_CONFIGURATION_GLOBAL_RULES")] - public List GlobalRules { get; set; } + public string[] GlobalRules { get; set; } = new string[0]; [LocalizedDisplayName("WEBFRONT_CONFIGURATION_DISALLOWED_NAMES")] - public List DisallowedClientNames { get; set; } + public string[] DisallowedClientNames { get; set; } = new string[0]; [UIHint("ServerConfiguration")] - public List Servers { get; set; } - + public ServerConfiguration[] Servers { get; set; } [ConfigurationIgnore] public string Id { get; set; } [ConfigurationIgnore] - public List Maps { get; set; } + public MapConfiguration[] Maps { get; set; } [ConfigurationIgnore] - public List QuickMessages { get; set; } + public QuickMessageConfiguration[] QuickMessages { get; set; } [ConfigurationIgnore] public string WebfrontUrl => string.IsNullOrEmpty(ManualWebfrontUrl) ? WebfrontBindUrl?.Replace("0.0.0.0", "127.0.0.1") : ManualWebfrontUrl; diff --git a/SharedLibraryCore/Configuration/DefaultConfiguration.cs b/SharedLibraryCore/Configuration/DefaultConfiguration.cs index 9a2b17dfb..d6f560906 100644 --- a/SharedLibraryCore/Configuration/DefaultConfiguration.cs +++ b/SharedLibraryCore/Configuration/DefaultConfiguration.cs @@ -1,17 +1,14 @@ using SharedLibraryCore.Interfaces; -using System; -using System.Collections.Generic; -using System.Text; namespace SharedLibraryCore.Configuration { public class DefaultConfiguration : IBaseConfiguration { - public List AutoMessages { get; set; } - public List GlobalRules { get; set; } - public List Maps { get; set; } - public List QuickMessages {get; set;} - public List DisallowedClientNames { get; set; } + public string[] AutoMessages { get; set; } + public string[] GlobalRules { get; set; } + public MapConfiguration[] Maps { get; set; } + public QuickMessageConfiguration[] QuickMessages {get; set;} + public string[] DisallowedClientNames { get; set; } public IBaseConfiguration Generate() => this; diff --git a/SharedLibraryCore/Configuration/ServerConfiguration.cs b/SharedLibraryCore/Configuration/ServerConfiguration.cs index 082bf71b8..f00e0ec4e 100644 --- a/SharedLibraryCore/Configuration/ServerConfiguration.cs +++ b/SharedLibraryCore/Configuration/ServerConfiguration.cs @@ -15,9 +15,9 @@ namespace SharedLibraryCore.Configuration [LocalizedDisplayName("WEBFRONT_CONFIGURATION_SERVER_PASSWORD")] public string Password { get; set; } [LocalizedDisplayName("WEBFRONT_CONFIGURATION_SERVER_RULES")] - public List Rules { get; set; } + public string[] Rules { get; set; } = new string[0]; [LocalizedDisplayName("WEBFRONT_CONFIGURATION_SERVER_AUTO_MESSAGES")] - public List AutoMessages { get; set; } + public string[] AutoMessages { get; set; } = new string[0]; [LocalizedDisplayName("WEBFRONT_CONFIGURATION_SERVER_PATH")] [ConfigurationOptional] public string ManualLogPath { get; set; } @@ -31,6 +31,9 @@ namespace SharedLibraryCore.Configuration [ConfigurationOptional] public Uri GameLogServerUrl { get; set; } + [ConfigurationIgnore] + public int Index { get; set; } + private readonly IList rconParsers; private readonly IList eventParsers; @@ -38,8 +41,8 @@ namespace SharedLibraryCore.Configuration { rconParsers = new List(); eventParsers = new List(); - Rules = new List(); - AutoMessages = new List(); + Rules = new string[0]; + AutoMessages = new string[0]; } public void AddRConParser(IRConParser parser) @@ -94,8 +97,8 @@ namespace SharedLibraryCore.Configuration Port = Utilities.PromptInt(loc["SETUP_SERVER_PORT"], null, 1, ushort.MaxValue); Password = Utilities.PromptString(loc["SETUP_SERVER_RCON"]); - AutoMessages = new List(); - Rules = new List(); + AutoMessages = new string[0]; + Rules = new string[0]; ReservedSlotNumber = loc["SETUP_SERVER_RESERVEDSLOT"].PromptInt(null, 0, 32); ManualLogPath = null; diff --git a/SharedLibraryCore/Configuration/Validation/ApplicationConfigurationValidator.cs b/SharedLibraryCore/Configuration/Validation/ApplicationConfigurationValidator.cs new file mode 100644 index 000000000..24c7de9b3 --- /dev/null +++ b/SharedLibraryCore/Configuration/Validation/ApplicationConfigurationValidator.cs @@ -0,0 +1,72 @@ +using FluentValidation; +using System; +using System.Linq; + +namespace SharedLibraryCore.Configuration.Validation +{ + /// + /// Validation class for main application configuration + /// + public class ApplicationConfigurationValidator : AbstractValidator + { + public ApplicationConfigurationValidator() + { + RuleFor(_app => _app.WebfrontBindUrl) + .NotEmpty(); + + RuleFor(_app => _app.CustomSayName) + .NotEmpty() + .When(_app => _app.EnableCustomSayName); + + RuleFor(_app => _app.SocialLinkAddress) + .NotEmpty() + .When(_app => _app.EnableSocialLink); + + RuleFor(_app => _app.SocialLinkTitle) + .NotEmpty() + .When(_app => _app.EnableSocialLink); + + RuleFor(_app => _app.CustomParserEncoding) + .NotEmpty() + .When(_app => _app.EnableCustomParserEncoding); + + RuleFor(_app => _app.WebfrontConnectionWhitelist) + .NotEmpty() + .When(_app => _app.EnableWebfrontConnectionWhitelist); + + RuleForEach(_app => _app.WebfrontConnectionWhitelist) + .Must(_address => System.Net.IPAddress.TryParse(_address, out _)); + + RuleFor(_app => _app.CustomLocale) + .NotEmpty() + .When(_app => _app.EnableCustomLocale); + + RuleFor(_app => _app.DatabaseProvider) + .NotEmpty() + .Must(_provider => new[] { "sqlite", "mysql", "postgresql" }.Contains(_provider)); + + RuleFor(_app => _app.ConnectionString) + .NotEmpty() + .When(_app => _app.DatabaseProvider != "sqlite"); + + RuleFor(_app => _app.RConPollRate) + .GreaterThanOrEqualTo(1000); + + RuleFor(_app => _app.AutoMessagePeriod) + .GreaterThanOrEqualTo(60); + + RuleFor(_app => _app.Servers) + .NotEmpty(); + + RuleFor(_app => _app.AutoMessages) + .NotNull(); + + RuleFor(_app => _app.GlobalRules) + .NotNull(); + + RuleForEach(_app => _app.Servers) + .NotEmpty() + .SetValidator(new ServerConfigurationValidator()); + } + } +} diff --git a/SharedLibraryCore/Configuration/Validation/ServerConfigurationValidator.cs b/SharedLibraryCore/Configuration/Validation/ServerConfigurationValidator.cs new file mode 100644 index 000000000..a5877013f --- /dev/null +++ b/SharedLibraryCore/Configuration/Validation/ServerConfigurationValidator.cs @@ -0,0 +1,39 @@ +using FluentValidation; +using System.Net; + +namespace SharedLibraryCore.Configuration.Validation +{ + /// + /// Validation class for server configuration + /// + public class ServerConfigurationValidator : AbstractValidator + { + public ServerConfigurationValidator() + { + RuleFor(_server => _server.IPAddress) + .NotEmpty() + .Must(_address => IPAddress.TryParse(_address, out _)); + + RuleFor(_server => _server.Port) + .InclusiveBetween(0, ushort.MaxValue); + + RuleFor(_server => _server.Password) + .NotEmpty(); + + RuleForEach(_server => _server.Rules) + .NotEmpty(); + + RuleForEach(_server => _server.AutoMessages) + .NotEmpty(); + + RuleFor(_server => _server.RConParserVersion) + .NotEmpty(); + + RuleFor(_server => _server.EventParserVersion) + .NotEmpty(); + + RuleFor(_server => _server.ReservedSlotNumber) + .InclusiveBetween(0, 32); + } + } +} diff --git a/SharedLibraryCore/Exceptions/ConfigurationException.cs b/SharedLibraryCore/Exceptions/ConfigurationException.cs new file mode 100644 index 000000000..009f52403 --- /dev/null +++ b/SharedLibraryCore/Exceptions/ConfigurationException.cs @@ -0,0 +1,11 @@ +using System; + +namespace SharedLibraryCore.Exceptions +{ + public class ConfigurationException : Exception + { + public string[] Errors { get; set; } + + public ConfigurationException(string message) : base(message) { } + } +} diff --git a/SharedLibraryCore/Helpers/BaseConfigurationHandler.cs b/SharedLibraryCore/Helpers/BaseConfigurationHandler.cs index 6790daf0f..90d726ba9 100644 --- a/SharedLibraryCore/Helpers/BaseConfigurationHandler.cs +++ b/SharedLibraryCore/Helpers/BaseConfigurationHandler.cs @@ -1,4 +1,5 @@ using Newtonsoft.Json; +using SharedLibraryCore.Exceptions; using SharedLibraryCore.Interfaces; using System; using System.IO; @@ -24,9 +25,12 @@ namespace SharedLibraryCore.Configuration var configContent = File.ReadAllText(_configurationPath); _configuration = JsonConvert.DeserializeObject(configContent); } - catch + catch (Exception e) { - _configuration = default(T); + throw new ConfigurationException("MANAGER_CONFIGURATION_ERROR") + { + Errors = new[] { e.Message } + }; } } diff --git a/SharedLibraryCore/ScriptPlugin.cs b/SharedLibraryCore/ScriptPlugin.cs index b59b94898..5428b43b9 100644 --- a/SharedLibraryCore/ScriptPlugin.cs +++ b/SharedLibraryCore/ScriptPlugin.cs @@ -104,6 +104,8 @@ namespace SharedLibraryCore { typeof(System.Net.Http.HttpClient).Assembly, typeof(EFClient).Assembly, + typeof(Utilities).Assembly, + typeof(Encoding).Assembly }) .CatchClrExceptions()); diff --git a/SharedLibraryCore/SharedLibraryCore.csproj b/SharedLibraryCore/SharedLibraryCore.csproj index 18516f3d4..c1bc09f6b 100644 --- a/SharedLibraryCore/SharedLibraryCore.csproj +++ b/SharedLibraryCore/SharedLibraryCore.csproj @@ -6,7 +6,7 @@ RaidMax.IW4MAdmin.SharedLibraryCore - 2.2.4 + 2.2.5 RaidMax Forever None Debug;Release;Prerelease @@ -20,8 +20,8 @@ true MIT Shared Library for IW4MAdmin - 2.2.4.0 - 2.2.4.0 + 2.2.5.0 + 2.2.5.0 @@ -46,21 +46,22 @@ + - - - + + + all runtime; build; native; contentfiles - - - - - - + + + + + + diff --git a/WebfrontCore/Controllers/ConfigurationController.cs b/WebfrontCore/Controllers/ConfigurationController.cs index 301b02bbe..2e6edf489 100644 --- a/WebfrontCore/Controllers/ConfigurationController.cs +++ b/WebfrontCore/Controllers/ConfigurationController.cs @@ -2,8 +2,11 @@ using Microsoft.AspNetCore.Mvc; using SharedLibraryCore; using SharedLibraryCore.Configuration; +using SharedLibraryCore.Configuration.Attributes; +using SharedLibraryCore.Configuration.Validation; using SharedLibraryCore.Interfaces; using System.Linq; +using System.Reflection; using System.Threading.Tasks; using WebfrontCore.ViewModels; @@ -12,11 +15,17 @@ namespace WebfrontCore.Controllers [Authorize] public class ConfigurationController : BaseController { - public ConfigurationController(IManager manager) : base (manager) - { + private readonly ApplicationConfigurationValidator _validator; + public ConfigurationController(IManager manager) : base(manager) + { + _validator = new ApplicationConfigurationValidator(); } + /// + /// Endpoint to get the current configuration view + /// + /// public IActionResult Edit() { if (Client.Level < SharedLibraryCore.Database.Models.EFClient.Permission.Owner) @@ -27,56 +36,132 @@ namespace WebfrontCore.Controllers return View("Index", Manager.GetApplicationSettings().Configuration()); } + /// + /// Endpoint for the save action + /// + /// bound configuration + /// [HttpPost] - public async Task Edit(ApplicationConfiguration newConfiguration, bool addNewServer = false, bool shouldSave = false) + public async Task Save(ApplicationConfiguration newConfiguration) + { + // todo: make this authorization middleware instead of these checks + if (Client.Level < SharedLibraryCore.Database.Models.EFClient.Permission.Owner) + { + return Unauthorized(); + } + + CleanConfiguration(newConfiguration); + var validationResult = _validator.Validate(newConfiguration); + + if (validationResult.IsValid) + { + var currentConfiguration = Manager.GetApplicationSettings().Configuration(); + CopyConfiguration(newConfiguration, currentConfiguration); + await Manager.GetApplicationSettings().Save(); + return Ok(new { message = new[] { Utilities.CurrentLocalization.LocalizationIndex["WEBFRONT_CONFIGURATION_SAVED"] } }); + } + + else + { + return BadRequest(new { message = Utilities.CurrentLocalization.LocalizationIndex["WEBFRONT_CONFIGURATION_SAVE_FAILED"], errors = new[] { validationResult.Errors.Select(_error => _error.ErrorMessage) } }); + } + } + + /// + /// Cleans the configuration by removing empty items from from the array + /// + /// + private void CleanConfiguration(ApplicationConfiguration newConfiguration) + { + void cleanProperties(object config) + { + foreach (var property in config.GetType() + .GetProperties().Where(_prop => _prop.CanWrite)) + { + var newPropValue = property.GetValue(config); + + if (newPropValue is ServerConfiguration[] serverConfig) + { + foreach (var c in serverConfig) + { + cleanProperties(c); + } + } + + // this clears out any null or empty items in the string array + if (newPropValue is string[] configArray) + { + newPropValue = configArray.Where(_str => !string.IsNullOrWhiteSpace(_str)).ToArray(); + } + + property.SetValue(config, newPropValue); + } + } + + cleanProperties(newConfiguration); + } + + /// + /// Copies required config fields from new to old + /// + /// Source config + /// Destination config + private void CopyConfiguration(ApplicationConfiguration newConfiguration, ApplicationConfiguration oldConfiguration) + { + foreach (var property in newConfiguration.GetType() + .GetProperties().Where(_prop => _prop.CanWrite)) + { + var newPropValue = property.GetValue(newConfiguration); + bool isPropNullArray = property.PropertyType.IsArray && newPropValue == null; + + // this prevents us from setting a null array as that could screw reading up + if (!ShouldIgnoreProperty(property) && !isPropNullArray) + { + property.SetValue(oldConfiguration, newPropValue); + } + } + } + + /// + /// Generates the partial view for a new list item + /// + /// name of the property the input element is generated for + /// how many items exist already + /// if it's a server property, which one + /// + public IActionResult GetNewListItem(string propertyName, int itemCount, int serverIndex = -1) { if (Client.Level < SharedLibraryCore.Database.Models.EFClient.Permission.Owner) { return Unauthorized(); } - if (shouldSave) + // todo: maybe make this cleaner in the future + if (propertyName.StartsWith("Servers") && serverIndex < 0) { - var currentConfiguration = Manager.GetApplicationSettings().Configuration(); - - var newConfigurationProperties = newConfiguration.GetType().GetProperties(); - foreach (var property in currentConfiguration.GetType().GetProperties()) + return PartialView("_ServerItem", new ApplicationConfiguration() { - var newProp = newConfigurationProperties.First(_prop => _prop.Name == property.Name); - var newPropValue = newProp.GetValue(newConfiguration); - - if (newPropValue != null && newProp.CanWrite) - { - property.SetValue(currentConfiguration, newPropValue); - } - } - - await Manager.GetApplicationSettings().Save(); + Servers = Enumerable.Repeat(new ServerConfiguration(), itemCount + 1).ToArray() + }); } - if (addNewServer) + var model = new BindingHelper() { - newConfiguration.Servers.Add(new ServerConfiguration()); - } - - - return View("Index", newConfiguration); - } - - public IActionResult GetNewListItem(string propertyName, int itemCount) - { - if (Client.Level != SharedLibraryCore.Database.Models.EFClient.Permission.Owner) - { - return Unauthorized(); - } - - var configInfo = new ConfigurationInfo() - { - NewItemCount = itemCount, - PropertyName = propertyName + Properties = propertyName.Split("."), + ItemIndex = itemCount, + ParentItemIndex = serverIndex }; - return PartialView("_ListItem", configInfo); + return PartialView("_ListItem", model); } + + /// + /// Indicates if the property should be ignored when cleaning/copying from one config to another + /// + /// property info of the current property + /// + private bool ShouldIgnoreProperty(PropertyInfo info) => (info.GetCustomAttributes(false) + .Where(_attr => _attr.GetType() == typeof(ConfigurationIgnore)) + .FirstOrDefault() as ConfigurationIgnore) != null; } } \ No newline at end of file diff --git a/WebfrontCore/Middleware/IPWhitelist.cs b/WebfrontCore/Middleware/IPWhitelist.cs index 16b34cb82..85d8c1634 100644 --- a/WebfrontCore/Middleware/IPWhitelist.cs +++ b/WebfrontCore/Middleware/IPWhitelist.cs @@ -1,5 +1,5 @@ using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging; +using SharedLibraryCore.Interfaces; using System; using System.Collections.Generic; using System.Linq; @@ -13,32 +13,39 @@ namespace WebfrontCore.Middleware /// internal sealed class IPWhitelist { - private readonly List whitelistedIps; - private readonly RequestDelegate nextRequest; + private readonly byte[][] _whitelistedIps; + private readonly RequestDelegate _nextRequest; + private readonly ILogger _logger; /// /// constructor /// /// - /// /// list of textual ip addresses - public IPWhitelist(RequestDelegate nextRequest, ILogger logger, List whitelistedIps) + public IPWhitelist(RequestDelegate nextRequest, ILogger logger, string[] whitelistedIps) { - this.whitelistedIps = whitelistedIps.Select(_ip => System.Net.IPAddress.Parse(_ip).GetAddressBytes()).ToList(); - this.nextRequest = nextRequest; + _whitelistedIps = whitelistedIps.Select(_ip => System.Net.IPAddress.Parse(_ip).GetAddressBytes()).ToArray(); + _nextRequest = nextRequest; + _logger = logger; } public async Task Invoke(HttpContext context) { - bool isAlllowed = whitelistedIps.Any(_ip => _ip.SequenceEqual(context.Connection.RemoteIpAddress.GetAddressBytes())); + bool isAlllowed = true; + + if (_whitelistedIps.Length > 0) + { + isAlllowed = _whitelistedIps.Any(_ip => _ip.SequenceEqual(context.Connection.RemoteIpAddress.GetAddressBytes())); + } if (isAlllowed) { - await nextRequest.Invoke(context); + await _nextRequest.Invoke(context); } else { + _logger.WriteInfo($"Blocking HTTP request from {context.Connection.RemoteIpAddress.ToString()}"); context.Abort(); } } diff --git a/WebfrontCore/Startup.cs b/WebfrontCore/Startup.cs index 42cc5cfba..1ffedd01a 100644 --- a/WebfrontCore/Startup.cs +++ b/WebfrontCore/Startup.cs @@ -96,7 +96,7 @@ namespace WebfrontCore } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. - public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ILoggerFactory loggerFactory) + public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ILoggerFactory loggerFactory, IManager manager) { app.UseStatusCodePages(_context => { @@ -120,7 +120,7 @@ namespace WebfrontCore if (Program.Manager.GetApplicationSettings().Configuration().EnableWebfrontConnectionWhitelist) { - app.UseMiddleware(Program.Manager.GetApplicationSettings().Configuration().WebfrontConnectionWhitelist); + app.UseMiddleware(manager.GetLogger(0), manager.GetApplicationSettings().Configuration().WebfrontConnectionWhitelist); } app.UseStaticFiles(); diff --git a/WebfrontCore/ViewModels/BindingHelper.cs b/WebfrontCore/ViewModels/BindingHelper.cs new file mode 100644 index 000000000..d45e89b45 --- /dev/null +++ b/WebfrontCore/ViewModels/BindingHelper.cs @@ -0,0 +1,23 @@ +namespace WebfrontCore.ViewModels +{ + /// + /// Helper class that hold information to assist with binding lists of items + /// + public class BindingHelper + { + /// + /// Sequential property mapping items + /// + public string[] Properties { get; set; } + + /// + /// Index in the array this new item lives + /// + public int ItemIndex { get; set; } + + /// + /// Index in the array of the parent item + /// + public int ParentItemIndex { get; set; } + } +} diff --git a/WebfrontCore/ViewModels/ConfigurationInfo.cs b/WebfrontCore/ViewModels/ConfigurationInfo.cs deleted file mode 100644 index da5901d09..000000000 --- a/WebfrontCore/ViewModels/ConfigurationInfo.cs +++ /dev/null @@ -1,19 +0,0 @@ -using SharedLibraryCore.Interfaces; -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using System.Threading.Tasks; - -namespace WebfrontCore.ViewModels -{ - public class ConfigurationInfo - { - public string PropertyName { get; set; } - public PropertyInfo PropertyInfo { get; set; } - public IList PropertyValue { get; set; } - public IBaseConfiguration Configuration { get; set; } - public int NewItemCount { get; set; } - } -} diff --git a/WebfrontCore/Views/Configuration/Index.cshtml b/WebfrontCore/Views/Configuration/Index.cshtml index 4035d736e..1037aa9c9 100644 --- a/WebfrontCore/Views/Configuration/Index.cshtml +++ b/WebfrontCore/Views/Configuration/Index.cshtml @@ -1,4 +1,5 @@ @using SharedLibraryCore.Configuration.Attributes +@using SharedLibraryCore.Configuration; @model SharedLibraryCore.Configuration.ApplicationConfiguration @{ @@ -39,7 +40,7 @@

@ViewData["Title"]

@noticeText
-
+ @foreach (var property in properties) { if (shouldIgnore(property)) @@ -49,22 +50,36 @@ string[] linkedPropertyNames = getLinkedPropertyName(property); - if (property.PropertyType.Name == typeof(System.Boolean).Name) + // bool type + if (property.PropertyType == typeof(bool)) {
- @Html.Editor(property.Name, linkedPropertyNames.Length > 0 ? new { htmlAttributes = new { @class= "has-related-content mb-0", data_related_content = string.Join(',', linkedPropertyNames.Select(_id => $"#{_id}_content")) } } : null) + @Html.Editor(property.Name, linkedPropertyNames.Length > 0 ? new { htmlAttributes = new { @class = "has-related-content mb-0", data_related_content = string.Join(',', linkedPropertyNames.Select(_id => $"#{_id}_content")) } } : null) @Html.Label(property.Name, null, new { @class = "form-check-label ml-1" })
} - else if (property.PropertyType.Name.Contains("List")) + // array type + else if (property.PropertyType.IsArray) { - if (hasLinkedParent(property)) + // special type for server config, I don't like this but for now it's ok + @if (property.PropertyType.GetElementType() == typeof(ServerConfiguration)) + { +
+ @for (int i = 0; i < Model.Servers.Length; i++) + { + @Html.EditorFor(model => model.Servers[i]); + } + @addServerText +
+ } + + else if (hasLinkedParent(property)) {
@if (linkedPropertyNames.Length == 0) { - @Html.Label(property.Name, null, new { @class = "mt-2" }) + @Html.Label(property.Name, null, new { @class = "mt-2 d-block" }) } @Html.Editor(property.Name, new { htmlAttributes = new { @class = $"form-group form-control bg-dark text-white-50 {(linkedPropertyNames.Length == 0 ? "mb-3" : "mb-0")}" } }) @addText @@ -76,14 +91,7 @@ @Html.Label(property.Name, null, new { @class = "bg-primary pl-3 pr-3 p-2 mb-0 w-100" })
@Html.Editor(property.Name, new { htmlAttributes = new { @class = "form-control bg-dark text-white-50 mt-3 mb-3", placeholder = isOptional(property) ? optionalText : "" } }) - @if (property.PropertyType.GenericTypeArguments[0].Name == "ServerConfiguration") - { - - } - else - { - @addText - } + @addText
} } @@ -107,7 +115,7 @@ } } } - +
diff --git a/WebfrontCore/Views/Configuration/_ListItem.cshtml b/WebfrontCore/Views/Configuration/_ListItem.cshtml index 47a0d9767..57af4b2d4 100644 --- a/WebfrontCore/Views/Configuration/_ListItem.cshtml +++ b/WebfrontCore/Views/Configuration/_ListItem.cshtml @@ -1,3 +1,13 @@ -@model WebfrontCore.ViewModels.ConfigurationInfo +@model WebfrontCore.ViewModels.BindingHelper - \ No newline at end of file +@if (Model.Properties.Length == 1) +{ + + @Html.Editor($"{Model.Properties.Last()}[{Model.ItemIndex}]", new { htmlAttributes = new { @class = "form-control bg-dark text-white-50 mb-2 text-box single-line" } }) +} + +@if (Model.Properties.Length > 1) +{ + + @Html.Editor($"{Model.Properties.First()}[{Model.ParentItemIndex}].{Model.Properties.Last()}[{Model.ItemIndex}]", new { htmlAttributes = new { @class = "form-control bg-dark text-white-50 mb-2 text-box single-line" } }) +} diff --git a/WebfrontCore/Views/Configuration/_ServerItem.cshtml b/WebfrontCore/Views/Configuration/_ServerItem.cshtml new file mode 100644 index 000000000..44ea3fb3d --- /dev/null +++ b/WebfrontCore/Views/Configuration/_ServerItem.cshtml @@ -0,0 +1,10 @@ +@model SharedLibraryCore.Configuration.ApplicationConfiguration +@{ + int start = Model.Servers.Length - 1; + int end = start + 1; +} + +@for (int i = start; i < end; i++) +{ + @Html.EditorFor(model => model.Servers[i]); +} \ No newline at end of file diff --git a/WebfrontCore/Views/Shared/EditorTemplates/ServerConfiguration.cshtml b/WebfrontCore/Views/Shared/EditorTemplates/ServerConfiguration.cshtml index aab157a81..4f3dd008a 100644 --- a/WebfrontCore/Views/Shared/EditorTemplates/ServerConfiguration.cshtml +++ b/WebfrontCore/Views/Shared/EditorTemplates/ServerConfiguration.cshtml @@ -1,52 +1,59 @@ -@model IList +@model SharedLibraryCore.Configuration.ServerConfiguration @{ string labelClass = "mb-2 mt-1"; string editorClass = "form-control bg-dark text-white-50 text-box single-line mb-2 mt-0"; string addText = SharedLibraryCore.Utilities.CurrentLocalization.LocalizationIndex["WEBFRONT_CONFIGURATION_ADD"]; string optionalText = SharedLibraryCore.Utilities.CurrentLocalization.LocalizationIndex["COMMAND_HELP_OPTIONAL"]; - int startAt = ViewBag.AddNew ?? false ? Model.Count - 1 : 0; + int i = 0; } -@for (int i = startAt; i < Model.Count; i++) -{ -
@Model[i].IPAddress:@Model[i].Port
+
+
+
@Model.IPAddress:@Model.Port
+ +
- @Html.LabelFor(model => model[i].IPAddress, null, new { @class = labelClass }) - @Html.EditorFor(model => model[i].IPAddress, new { htmlAttributes = new { @class = editorClass } }) + + - @Html.LabelFor(model => model[i].Port, null, new { @class = labelClass }) - @Html.EditorFor(model => model[i].Port, new { htmlAttributes = new { @class = editorClass } }) + + - @Html.LabelFor(model => model[i].Password, null, new { @class = labelClass }) - @Html.EditorFor(model => model[i].Password, new { htmlAttributes = new { @class = editorClass } }) + + - @Html.LabelFor(model => model[i].ManualLogPath, null, new { @class = labelClass }) - @Html.EditorFor(model => model[i].ManualLogPath, new { htmlAttributes = new { @class = editorClass, placeholder = optionalText } }) + + - @Html.LabelFor(model => model[i].GameLogServerUrl, null, new { @class = labelClass }) - @Html.EditorFor(model => model[i].GameLogServerUrl, new { htmlAttributes = new { @class = editorClass, placeholder = optionalText } }) + + - @Html.LabelFor(model => model[i].RConParserVersion, null, new { @class = labelClass }) - @Html.EditorFor(model => model[i].RConParserVersion, new { htmlAttributes = new { @class = editorClass } }) + + - @Html.LabelFor(model => model[i].EventParserVersion, null, new { @class = labelClass }) - @Html.EditorFor(model => model[i].EventParserVersion, new { htmlAttributes = new { @class = editorClass } }) + + - @Html.LabelFor(model => model[i].ReservedSlotNumber, null, new { @class = labelClass }) - @Html.EditorFor(model => model[i].ReservedSlotNumber, new { htmlAttributes = new { @class = editorClass } }) + +
- @Html.LabelFor(model => model[i].Rules, null, new { @class = "bg-primary pl-3 pr-3 p-2 w-100 mt-3" }) - @Html.EditorFor(model => model[i].Rules, new { htmlAttributes = new { @class = editorClass } }) - @addText + + @for(i = 0; i < Model.Rules.Length; i++) + { + + } + @addText
- @Html.LabelFor(model => model[i].AutoMessages, null, new { @class = "bg-primary pl-3 pr-3 p-2 w-100 mt-3" }) - @Html.EditorFor(model => model[i].AutoMessages, new { htmlAttributes = new { @class = editorClass } }) - @addText + + @for(i = 0; i < Model.AutoMessages.Length; i++) + { + + } + @addText
-} -@**@ +
diff --git a/WebfrontCore/WebfrontCore.csproj b/WebfrontCore/WebfrontCore.csproj index d3d8ddbe7..a8e9b0fe1 100644 --- a/WebfrontCore/WebfrontCore.csproj +++ b/WebfrontCore/WebfrontCore.csproj @@ -70,11 +70,7 @@
- - - - - + @@ -98,6 +94,6 @@ - + diff --git a/WebfrontCore/wwwroot/js/configuration.js b/WebfrontCore/wwwroot/js/configuration.js index 37028847b..3839874f4 100644 --- a/WebfrontCore/wwwroot/js/configuration.js +++ b/WebfrontCore/wwwroot/js/configuration.js @@ -1,23 +1,76 @@ $(document).ready(function() { - $.each($('.has-related-content'), function (key, value) { + $.each($('.has-related-content'), function(key, value) { value = $(value); if (value.attr('checked') !== undefined && value.attr('checked').length > 0) { $(value.data('related-content')).slideDown(); } }); - $('input:checkbox').change(function () { + $('input:checkbox').change(function() { var isChecked = $(this).is(':checked'); isChecked ? $($(this).data('related-content')).slideDown() : $($(this).data('related-content')).slideUp(); }); - $('.configuration-add-new').click(function (e) { + // this is used for regular simple form adds + $(document).on('click', '.configuration-add-new', function(e) { e.preventDefault(); - - let parentElement = $(this).parent(); - $.get($(this).attr('href') + '&itemCount=' + $(this).siblings().length, function (response) { + let parentElement = $(this).parent(); + let label = $(this).siblings('label'); + let forAttr = $(label).attr('for'); + let match = /Servers_+([0-9+])_+.*/g.exec(forAttr); + let additionalData = ''; + if (match !== null && match.length === 2) { + additionalData = '&serverIndex=' + match[1].toString(); + } + + $.get($(this).attr('href') + '&itemCount=' + $(this).siblings('input').length.toString() + additionalData, function (response) { $(response).insertBefore(parentElement.children().last()); }); }); + + // this is used for server adds which are little more complex + $(document).on('click', '.configuration-server-add-new', function (e) { + e.preventDefault(); + + let parentElement = $(this).parent(); + + $.get($(this).attr('href') + '&itemCount=' + $('.server-configuration-header').length.toString(), function (response) { + $(response).insertBefore(parentElement.children().last()); + }); + }); + + // removes the server when clicking the delete button + $(document).on('click', '.delete-server-button', function (e) { + $(this).parents('.server-configuration-header').remove(); + }); + + $('#configurationForm').submit(function (e) { + $.ajax({ + data: $(this).serialize(), + type: $(this).attr('method'), + url: $(this).attr('action'), + complete: function(response) { + if (response.status === 200) { + $('#actionModal .modal-message').removeClass('text-danger'); + $('#actionModal').data('should-refresh', true); + } + else { + $('#actionModal .modal-message').addClass('text-danger'); + } + $('#actionModal .modal-body-content').html(''); + let errors = ''; + + if (response.responseJSON.errors !== undefined) { + errors = response.responseJSON.errors[0].join('
'); + } + message = response.responseJSON.message; + $('#actionModal .modal-message').html(message + '
' + errors); + $('#actionModal').modal(); + $('#actionModal .modal-message').fadeIn('fast'); + } + }); + + return false; + }); }); \ No newline at end of file diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 4554dc903..99af89479 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -85,7 +85,7 @@ steps: echo changing to encoding for linux start script dos2unix $(outputFolder)\StartIW4MAdmin.sh echo creating website version filename - @echo $(Version.Major).$(Version.Minor).$(Version.Build).$(Build.BuildId) > $(Build.ArtifactStagingDirectory)\version_prerelease.txt + @echo IW4MAdmin-$(Version.Major).$(Version.Minor)-$(buildConfiguration)$(Version.Build)b$(Build.BuildId) > $(Build.ArtifactStagingDirectory)\version_prerelease.txt workingDirectory: '$(Build.Repository.LocalPath)\Application\BuildScripts' - task: CopyFiles@2