diff --git a/Application/API/Master/IMasterApi.cs b/Application/API/Master/IMasterApi.cs index 2b99422af..c96689f78 100644 --- a/Application/API/Master/IMasterApi.cs +++ b/Application/API/Master/IMasterApi.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; -using IW4MAdmin.Application.Misc; +using IW4MAdmin.Application.Plugin; using Newtonsoft.Json; using RestEase; using SharedLibraryCore.Helpers; diff --git a/Application/Application.csproj b/Application/Application.csproj index ed3e9d87c..30ff32291 100644 --- a/Application/Application.csproj +++ b/Application/Application.csproj @@ -25,7 +25,7 @@ - + all diff --git a/Application/ApplicationManager.cs b/Application/ApplicationManager.cs index b0c435f7c..165ad1f12 100644 --- a/Application/ApplicationManager.cs +++ b/Application/ApplicationManager.cs @@ -24,6 +24,7 @@ using System.Threading.Tasks; using Data.Abstractions; using Data.Context; using IW4MAdmin.Application.Migration; +using IW4MAdmin.Application.Plugin.Script; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Serilog.Context; diff --git a/Application/Extensions/CommandExtensions.cs b/Application/Extensions/CommandExtensions.cs index 8004baf3c..d26813639 100644 --- a/Application/Extensions/CommandExtensions.cs +++ b/Application/Extensions/CommandExtensions.cs @@ -1,8 +1,8 @@ using System; using System.Collections.Generic; -using IW4MAdmin.Application.Misc; using SharedLibraryCore.Interfaces; using System.Linq; +using IW4MAdmin.Application.Plugin.Script; using SharedLibraryCore; using SharedLibraryCore.Configuration; diff --git a/Application/Extensions/ScriptPluginExtensions.cs b/Application/Extensions/ScriptPluginExtensions.cs index 00c835844..b6a2c87c4 100644 --- a/Application/Extensions/ScriptPluginExtensions.cs +++ b/Application/Extensions/ScriptPluginExtensions.cs @@ -2,7 +2,6 @@ using System.Linq; using Data.Models.Client.Stats; using Microsoft.EntityFrameworkCore; -using SharedLibraryCore; namespace IW4MAdmin.Application.Extensions; @@ -26,9 +25,4 @@ public static class ScriptPluginExtensions { return set.Where(stat => clientIds.Contains(stat.ClientId) && stat.ServerId == (long)serverId).ToList(); } - - public static object GetId(this Server server) - { - return server.GetIdForServer().GetAwaiter().GetResult(); - } } diff --git a/Application/Factories/ScriptCommandFactory.cs b/Application/Factories/ScriptCommandFactory.cs index 7d7cdf806..94c282b9a 100644 --- a/Application/Factories/ScriptCommandFactory.cs +++ b/Application/Factories/ScriptCommandFactory.cs @@ -1,13 +1,13 @@ -using IW4MAdmin.Application.Misc; -using SharedLibraryCore; +using SharedLibraryCore; using SharedLibraryCore.Commands; using SharedLibraryCore.Configuration; using SharedLibraryCore.Interfaces; using System; using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; +using Data.Models; using Data.Models.Client; +using IW4MAdmin.Application.Plugin.Script; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -31,16 +31,11 @@ namespace IW4MAdmin.Application.Factories /// public IManagerCommand CreateScriptCommand(string name, string alias, string description, string permission, - bool isTargetRequired, IEnumerable<(string, bool)> args, Func executeAction, Server.Game[] supportedGames) + bool isTargetRequired, IEnumerable args, Func executeAction, IEnumerable supportedGames) { var permissionEnum = Enum.Parse(permission); - var argsArray = args.Select(_arg => new CommandArgument - { - Name = _arg.Item1, - Required = _arg.Item2 - }).ToArray(); - return new ScriptCommand(name, alias, description, isTargetRequired, permissionEnum, argsArray, executeAction, + return new ScriptCommand(name, alias, description, isTargetRequired, permissionEnum, args, executeAction, _config, _transLookup, _serviceProvider.GetRequiredService>(), supportedGames); } } diff --git a/Application/IW4MServer.cs b/Application/IW4MServer.cs index 6dfcd4f02..b10f27544 100644 --- a/Application/IW4MServer.cs +++ b/Application/IW4MServer.cs @@ -27,6 +27,8 @@ using Data.Models; using Data.Models.Server; using IW4MAdmin.Application.Alerts; using IW4MAdmin.Application.Commands; +using IW4MAdmin.Application.Plugin.Script; +using IW4MAdmin.Plugins.Stats.Helpers; using Microsoft.EntityFrameworkCore; using SharedLibraryCore.Alerts; using static Data.Models.Client.EFClient; diff --git a/Application/Main.cs b/Application/Main.cs index daa910395..1d7991106 100644 --- a/Application/Main.cs +++ b/Application/Main.cs @@ -30,6 +30,8 @@ using Integrations.Source.Extensions; using IW4MAdmin.Application.Alerts; using IW4MAdmin.Application.Extensions; using IW4MAdmin.Application.Localization; +using IW4MAdmin.Application.Plugin; +using IW4MAdmin.Application.Plugin.Script; using IW4MAdmin.Application.QueryHelpers; using Microsoft.Extensions.Logging; using ILogger = Microsoft.Extensions.Logging.ILogger; @@ -321,10 +323,13 @@ namespace IW4MAdmin.Application serviceCollection.AddSingleton(genericInterfaceType, handlerInstance); } - // register any script plugins - foreach (var plugin in pluginImporter.DiscoverScriptPlugins()) + var scriptPlugins = pluginImporter.DiscoverScriptPlugins(); + + foreach (var scriptPlugin in scriptPlugins) { - serviceCollection.AddSingleton(plugin); + serviceCollection.AddSingleton(scriptPlugin.Item1, sp => + sp.GetRequiredService() + .CreateScriptPlugin(scriptPlugin.Item1, scriptPlugin.Item2)); } // register any eventable types diff --git a/Application/Misc/InteractionRegistration.cs b/Application/Misc/InteractionRegistration.cs index 9d5216d98..a8c61ef88 100644 --- a/Application/Misc/InteractionRegistration.cs +++ b/Application/Misc/InteractionRegistration.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using Data.Models; +using IW4MAdmin.Application.Plugin.Script; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using SharedLibraryCore.Interfaces; @@ -86,8 +87,6 @@ public class InteractionRegistration : IInteractionRegistration int? clientId = null, Reference.Game? game = null, CancellationToken token = default) { - return Enumerable.Empty(); - // fixme: multi-threading is broken when dealing with script plugins return await GetInteractionsInternal(interactionPrefix, clientId, game, token); } @@ -120,6 +119,18 @@ public class InteractionRegistration : IInteractionRegistration return scriptPlugin.ExecuteAction(interaction.ScriptAction, token, originId, targetId, game, meta, token); } + + foreach (var plugin in _serviceProvider.GetRequiredService>()) + { + if (plugin is not ScriptPluginV2 scriptPlugin || scriptPlugin.Name != interaction.Source) + { + continue; + } + + return scriptPlugin + .QueryWithErrorHandling(interaction.ScriptAction, originId, targetId, game, meta, token) + ?.ToString(); + } } } catch (Exception ex) diff --git a/Application/Misc/ScriptPluginConfigurationWrapper.cs b/Application/Misc/ScriptPluginConfigurationWrapper.cs deleted file mode 100644 index 50aa4124e..000000000 --- a/Application/Misc/ScriptPluginConfigurationWrapper.cs +++ /dev/null @@ -1,96 +0,0 @@ -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Text.Json; -using System.Threading.Tasks; -using IW4MAdmin.Application.Configuration; -using Jint; -using Jint.Native; -using Newtonsoft.Json.Linq; - -namespace IW4MAdmin.Application.Misc -{ - public class ScriptPluginConfigurationWrapper - { - private readonly BaseConfigurationHandler _handler; - private ScriptPluginConfiguration _config; - private readonly string _pluginName; - private readonly Engine _scriptEngine; - - public ScriptPluginConfigurationWrapper(string pluginName, Engine scriptEngine) - { - _handler = new BaseConfigurationHandler("ScriptPluginSettings"); - _pluginName = pluginName; - _scriptEngine = scriptEngine; - } - - public async Task InitializeAsync() - { - await _handler.BuildAsync(); - _config = _handler.Configuration() ?? - (ScriptPluginConfiguration) new ScriptPluginConfiguration().Generate(); - } - - private static int? AsInteger(double d) - { - return int.TryParse(d.ToString(CultureInfo.InvariantCulture), out var parsed) ? parsed : (int?) null; - } - - public async Task SetValue(string key, object value) - { - var castValue = value; - - if (value is double d) - { - castValue = AsInteger(d) ?? value; - } - - if (value is object[] array && array.All(item => item is double d && AsInteger(d) != null)) - { - castValue = array.Select(item => AsInteger((double)item)).ToArray(); - } - - if (!_config.ContainsKey(_pluginName)) - { - _config.Add(_pluginName, new Dictionary()); - } - - var plugin = _config[_pluginName]; - - if (plugin.ContainsKey(key)) - { - plugin[key] = castValue; - } - - else - { - plugin.Add(key, castValue); - } - - _handler.Set(_config); - await _handler.Save(); - } - - public JsValue GetValue(string key) - { - if (!_config.ContainsKey(_pluginName)) - { - return JsValue.Undefined; - } - - if (!_config[_pluginName].ContainsKey(key)) - { - return JsValue.Undefined; - } - - var item = _config[_pluginName][key]; - - if (item is JsonElement { ValueKind: JsonValueKind.Array } jElem) - { - item = jElem.Deserialize>(); - } - - return JsValue.FromObject(_scriptEngine, item); - } - } -} diff --git a/Application/Misc/PluginImporter.cs b/Application/Plugin/PluginImporter.cs similarity index 64% rename from Application/Misc/PluginImporter.cs rename to Application/Plugin/PluginImporter.cs index 472f889bb..cc5cb0804 100644 --- a/Application/Misc/PluginImporter.cs +++ b/Application/Plugin/PluginImporter.cs @@ -1,17 +1,17 @@ using System; -using System.IO; using System.Collections.Generic; -using System.Reflection; -using SharedLibraryCore.Interfaces; +using System.IO; using System.Linq; -using SharedLibraryCore; +using System.Reflection; +using System.Text.RegularExpressions; using IW4MAdmin.Application.API.Master; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using SharedLibraryCore; using SharedLibraryCore.Configuration; +using SharedLibraryCore.Interfaces; using ILogger = Microsoft.Extensions.Logging.ILogger; -namespace IW4MAdmin.Application.Misc +namespace IW4MAdmin.Application.Plugin { /// /// implementation of IPluginImporter @@ -20,13 +20,15 @@ namespace IW4MAdmin.Application.Misc public class PluginImporter : IPluginImporter { private IEnumerable _pluginSubscription; - private static readonly string PLUGIN_DIR = "Plugins"; + private static readonly string PluginDir = "Plugins"; + private const string PluginV2Match = "^ *((?:var|const|let) +init)|function init"; private readonly ILogger _logger; private readonly IRemoteAssemblyHandler _remoteAssemblyHandler; private readonly IMasterApi _masterApi; private readonly ApplicationConfiguration _appConfig; - public PluginImporter(ILogger logger, ApplicationConfiguration appConfig, IMasterApi masterApi, IRemoteAssemblyHandler remoteAssemblyHandler) + public PluginImporter(ILogger logger, ApplicationConfiguration appConfig, IMasterApi masterApi, + IRemoteAssemblyHandler remoteAssemblyHandler) { _logger = logger; _masterApi = masterApi; @@ -38,25 +40,34 @@ namespace IW4MAdmin.Application.Misc /// discovers all the script plugins in the plugins dir /// /// - public IEnumerable DiscoverScriptPlugins() + public IEnumerable<(Type, string)> DiscoverScriptPlugins() { - var pluginDir = $"{Utilities.OperatingDirectory}{PLUGIN_DIR}{Path.DirectorySeparatorChar}"; + var pluginDir = $"{Utilities.OperatingDirectory}{PluginDir}{Path.DirectorySeparatorChar}"; if (!Directory.Exists(pluginDir)) { - return Enumerable.Empty(); + return Enumerable.Empty<(Type, string)>(); } var scriptPluginFiles = Directory.GetFiles(pluginDir, "*.js").AsEnumerable().Union(GetRemoteScripts()).ToList(); - _logger.LogDebug("Discovered {count} potential script plugins", scriptPluginFiles.Count); - - return scriptPluginFiles.Select(fileName => + var bothVersionPlugins = scriptPluginFiles.Select(fileName => { - _logger.LogDebug("Discovered script plugin {fileName}", fileName); - return new ScriptPlugin(_logger, fileName); + _logger.LogDebug("Discovered script plugin {FileName}", fileName); + try + { + var fileContents = File.ReadAllLines(fileName); + var isValidV2 = fileContents.Any(line => Regex.IsMatch(line, PluginV2Match)); + return isValidV2 ? (typeof(IPluginV2), fileName) : (typeof(IPlugin), fileName); + } + catch + { + return (typeof(IPlugin), fileName); + } }).ToList(); + + return bothVersionPlugins; } /// @@ -65,7 +76,7 @@ namespace IW4MAdmin.Application.Misc /// public (IEnumerable, IEnumerable, IEnumerable) DiscoverAssemblyPluginImplementations() { - var pluginDir = $"{Utilities.OperatingDirectory}{PLUGIN_DIR}{Path.DirectorySeparatorChar}"; + var pluginDir = $"{Utilities.OperatingDirectory}{PluginDir}{Path.DirectorySeparatorChar}"; var pluginTypes = Enumerable.Empty(); var commandTypes = Enumerable.Empty(); var configurationTypes = Enumerable.Empty(); @@ -73,45 +84,47 @@ namespace IW4MAdmin.Application.Misc if (Directory.Exists(pluginDir)) { var dllFileNames = Directory.GetFiles(pluginDir, "*.dll"); - _logger.LogDebug("Discovered {count} potential plugin assemblies", dllFileNames.Length); + _logger.LogDebug("Discovered {Count} potential plugin assemblies", dllFileNames.Length); if (dllFileNames.Length > 0) { // we only want to load the most recent assembly in case of duplicates - var assemblies = dllFileNames.Select(_name => Assembly.LoadFrom(_name)) + var assemblies = dllFileNames.Select(name => Assembly.LoadFrom(name)) .Union(GetRemoteAssemblies()) - .GroupBy(_assembly => _assembly.FullName).Select(_assembly => _assembly.OrderByDescending(_assembly => _assembly.GetName().Version).First()); + .GroupBy(assembly => assembly.FullName).Select(assembly => assembly.OrderByDescending(asm => asm.GetName().Version).First()); pluginTypes = assemblies - .SelectMany(_asm => + .SelectMany(asm => { try { - return _asm.GetTypes(); + return asm.GetTypes(); } catch { return Enumerable.Empty(); } }) - .Where(_assemblyType => _assemblyType.GetInterface(nameof(IPlugin), false) != null); + .Where(assemblyType => (assemblyType.GetInterface(nameof(IPlugin), false) ?? assemblyType.GetInterface(nameof(IPluginV2), false)) != null) + .Where(assemblyType => !assemblyType.Namespace?.StartsWith(nameof(SharedLibraryCore)) ?? false); _logger.LogDebug("Discovered {count} plugin implementations", pluginTypes.Count()); commandTypes = assemblies - .SelectMany(_asm =>{ + .SelectMany(asm =>{ try { - return _asm.GetTypes(); + return asm.GetTypes(); } catch { return Enumerable.Empty(); } }) - .Where(_assemblyType => _assemblyType.IsClass && _assemblyType.BaseType == typeof(Command)); + .Where(assemblyType => assemblyType.IsClass && assemblyType.BaseType == typeof(Command)) + .Where(assemblyType => !assemblyType.Namespace?.StartsWith(nameof(SharedLibraryCore)) ?? false); - _logger.LogDebug("Discovered {count} plugin commands", commandTypes.Count()); + _logger.LogDebug("Discovered {Count} plugin commands", commandTypes.Count()); configurationTypes = assemblies .SelectMany(asm => { @@ -125,9 +138,10 @@ namespace IW4MAdmin.Application.Misc } }) .Where(asmType => - asmType.IsClass && asmType.GetInterface(nameof(IBaseConfiguration), false) != null); + asmType.IsClass && asmType.GetInterface(nameof(IBaseConfiguration), false) != null) + .Where(assemblyType => !assemblyType.Namespace?.StartsWith(nameof(SharedLibraryCore)) ?? false); - _logger.LogDebug("Discovered {count} configuration implementations", configurationTypes.Count()); + _logger.LogDebug("Discovered {Count} configuration implementations", configurationTypes.Count()); } } @@ -155,8 +169,7 @@ namespace IW4MAdmin.Application.Misc { try { - if (_pluginSubscription == null) - _pluginSubscription = _masterApi.GetPluginSubscription(Guid.Parse(_appConfig.Id), _appConfig.SubscriptionId).Result; + _pluginSubscription ??= _masterApi.GetPluginSubscription(Guid.Parse(_appConfig.Id), _appConfig.SubscriptionId).Result; return _remoteAssemblyHandler.DecryptScripts(_pluginSubscription.Where(sub => sub.Type == PluginType.Script).Select(sub => sub.Content).ToArray()); } diff --git a/Application/Misc/ScriptCommand.cs b/Application/Plugin/Script/ScriptCommand.cs similarity index 74% rename from Application/Misc/ScriptCommand.cs rename to Application/Plugin/Script/ScriptCommand.cs index 4db84987a..8c0bd7654 100644 --- a/Application/Misc/ScriptCommand.cs +++ b/Application/Plugin/Script/ScriptCommand.cs @@ -1,14 +1,17 @@ -using SharedLibraryCore; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Data.Models; +using Data.Models.Client; +using Microsoft.Extensions.Logging; +using SharedLibraryCore; using SharedLibraryCore.Commands; using SharedLibraryCore.Configuration; using SharedLibraryCore.Interfaces; -using System; -using System.Threading.Tasks; -using Data.Models.Client; -using Microsoft.Extensions.Logging; using ILogger = Microsoft.Extensions.Logging.ILogger; -namespace IW4MAdmin.Application.Misc +namespace IW4MAdmin.Application.Plugin.Script { /// /// generic script command implementation @@ -20,8 +23,8 @@ namespace IW4MAdmin.Application.Misc public ScriptCommand(string name, string alias, string description, bool isTargetRequired, EFClient.Permission permission, - CommandArgument[] args, Func executeAction, CommandConfiguration config, - ITranslationLookup layout, ILogger logger, Server.Game[] supportedGames) + IEnumerable args, Func executeAction, CommandConfiguration config, + ITranslationLookup layout, ILogger logger, IEnumerable supportedGames) : base(config, layout) { _executeAction = executeAction; @@ -31,8 +34,8 @@ namespace IW4MAdmin.Application.Misc Description = description; RequiresTarget = isTargetRequired; Permission = permission; - Arguments = args; - SupportedGames = supportedGames; + Arguments = args.ToArray(); + SupportedGames = supportedGames?.Select(game => (Server.Game)game).ToArray(); } public override async Task ExecuteAsync(GameEvent e) @@ -48,7 +51,7 @@ namespace IW4MAdmin.Application.Misc } catch (Exception ex) { - _logger.LogError(ex, "Failed to execute ScriptCommand action for command {command} {@event}", Name, e); + _logger.LogError(ex, "Failed to execute ScriptCommand action for command {Command} {@Event}", Name, e); } } } diff --git a/Application/Misc/ScriptPlugin.cs b/Application/Plugin/Script/ScriptPlugin.cs similarity index 73% rename from Application/Misc/ScriptPlugin.cs rename to Application/Plugin/Script/ScriptPlugin.cs index 964d0e951..b5da929f3 100644 --- a/Application/Misc/ScriptPlugin.cs +++ b/Application/Plugin/Script/ScriptPlugin.cs @@ -1,12 +1,4 @@ using System; -using Jint; -using Jint.Native; -using Jint.Runtime; -using Microsoft.CSharp.RuntimeBinder; -using SharedLibraryCore; -using SharedLibraryCore.Database.Models; -using SharedLibraryCore.Exceptions; -using SharedLibraryCore.Interfaces; using System.Collections.Generic; using System.IO; using System.Linq; @@ -14,13 +6,25 @@ using System.Runtime.CompilerServices; using System.Text; using System.Threading; using System.Threading.Tasks; +using Data.Models; +using IW4MAdmin.Application.Configuration; using IW4MAdmin.Application.Extensions; +using IW4MAdmin.Application.Misc; +using Jint; +using Jint.Native; +using Jint.Runtime; using Jint.Runtime.Interop; +using Microsoft.CSharp.RuntimeBinder; using Microsoft.Extensions.Logging; using Serilog.Context; +using SharedLibraryCore; +using SharedLibraryCore.Commands; +using SharedLibraryCore.Database.Models; +using SharedLibraryCore.Exceptions; +using SharedLibraryCore.Interfaces; using ILogger = Microsoft.Extensions.Logging.ILogger; -namespace IW4MAdmin.Application.Misc +namespace IW4MAdmin.Application.Plugin.Script { /// /// implementation of IPlugin @@ -70,7 +74,7 @@ namespace IW4MAdmin.Application.Misc } public async Task Initialize(IManager manager, IScriptCommandFactory scriptCommandFactory, - IScriptPluginServiceResolver serviceResolver) + IScriptPluginServiceResolver serviceResolver, IConfigurationHandlerV2 configHandler) { try { @@ -130,11 +134,17 @@ namespace IW4MAdmin.Application.Misc .AddObjectConverter(new PermissionLevelToStringConverter())); _scriptEngine.Execute(script); + if (!_scriptEngine.GetValue("init").IsUndefined()) + { + // this is a v2 plugin and we don't want to try to load it + Watcher.EnableRaisingEvents = false; + Watcher.Dispose(); + return; + } _scriptEngine.SetValue("_localization", Utilities.CurrentLocalization); _scriptEngine.SetValue("_serviceResolver", serviceResolver); - _scriptEngine.SetValue("_lock", _onProcessing); dynamic pluginObject = _scriptEngine.Evaluate("plugin").ToObject(); - + Author = pluginObject.author; Name = pluginObject.name; Version = (float)pluginObject.version; @@ -191,8 +201,7 @@ namespace IW4MAdmin.Application.Misc catch (RuntimeBinderException) { - var configWrapper = new ScriptPluginConfigurationWrapper(Name, _scriptEngine); - await configWrapper.InitializeAsync(); + var configWrapper = new ScriptPluginConfigurationWrapper(Name, _scriptEngine, configHandler); if (!loadComplete) { @@ -252,7 +261,7 @@ namespace IW4MAdmin.Application.Misc try { - await _onProcessing.WaitAsync(); + await _onProcessing.WaitAsync(Utilities.DefaultCommandTimeout / 2); shouldRelease = true; WrapJavaScriptErrorHandling(() => { @@ -269,7 +278,6 @@ namespace IW4MAdmin.Application.Misc _onProcessing.Release(1); } } - } public Task OnLoadAsync(IManager manager) @@ -279,8 +287,6 @@ namespace IW4MAdmin.Application.Misc WrapJavaScriptErrorHandling(() => { _scriptEngine.SetValue("_manager", manager); - _scriptEngine.SetValue("getDvar", BeginGetDvar); - _scriptEngine.SetValue("setDvar", BeginSetDvar); return _scriptEngine.Evaluate("plugin.onLoadAsync(_manager)"); }); @@ -289,12 +295,6 @@ namespace IW4MAdmin.Application.Misc public Task OnTickAsync(Server server) { - WrapJavaScriptErrorHandling(() => - { - _scriptEngine.SetValue("_server", server); - return _scriptEngine.Evaluate("plugin.onTickAsync(_server)"); - }); - return Task.CompletedTask; } @@ -415,10 +415,10 @@ namespace IW4MAdmin.Application.Misc } string permission = dynamicCommand.permission; - List supportedGames = null; + List supportedGames = null; var targetRequired = false; - var args = new List<(string, bool)>(); + var args = new List(); dynamic arguments = null; try @@ -445,7 +445,7 @@ namespace IW4MAdmin.Application.Misc { foreach (var arg in dynamicCommand.arguments) { - args.Add((arg.name, (bool)arg.required)); + args.Add(new CommandArgument { Name = arg.name, Required = (bool)arg.required }); } } @@ -453,8 +453,8 @@ namespace IW4MAdmin.Application.Misc { foreach (var game in dynamicCommand.supportedGames) { - supportedGames ??= new List(); - supportedGames.Add(Enum.Parse(typeof(Server.Game), game.ToString())); + supportedGames ??= new List(); + supportedGames.Add(Enum.Parse(typeof(Reference.Game), game.ToString())); } } catch (RuntimeBinderException) @@ -507,175 +507,12 @@ namespace IW4MAdmin.Application.Misc } commandList.Add(scriptCommandFactory.CreateScriptCommand(name, alias, description, permission, - targetRequired, args, Execute, supportedGames?.ToArray())); + targetRequired, args, Execute, supportedGames)); } return commandList; } - private void BeginGetDvar(Server server, string dvarName, Delegate onCompleted) - { - var operationTimeout = TimeSpan.FromSeconds(5); - - void OnComplete(IAsyncResult result) - { - try - { - _onProcessing.Wait(); - - var (success, value) = (ValueTuple)result.AsyncState; - onCompleted.DynamicInvoke(JsValue.Undefined, - new[] - { - JsValue.FromObject(_scriptEngine, server), - JsValue.FromObject(_scriptEngine, dvarName), - JsValue.FromObject(_scriptEngine, value), - JsValue.FromObject(_scriptEngine, success) - }); - } - catch (JavaScriptException ex) - { - using (LogContext.PushProperty("Server", server.ToString())) - { - _logger.LogError(ex, "Could not invoke BeginGetDvar callback for {Filename} {@Location}", - Path.GetFileName(_fileName), ex.Location); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Could not complete {BeginGetDvar} for {Class}", nameof(BeginGetDvar), Name); - } - finally - { - if (_onProcessing.CurrentCount == 0) - { - _onProcessing.Release(1); - } - } - } - - new Thread(() => - { - if (DateTime.Now - (server.MatchEndTime ?? server.MatchStartTime) < TimeSpan.FromSeconds(15)) - { - using (LogContext.PushProperty("Server", server.ToString())) - { - _logger.LogDebug("Not getting DVar because match recently ended"); - } - - OnComplete(new AsyncResult - { - IsCompleted = false, - AsyncState = (false, (string)null) - }); - } - - using var tokenSource = new CancellationTokenSource(); - tokenSource.CancelAfter(operationTimeout); - - server.GetDvarAsync(dvarName, token: tokenSource.Token).ContinueWith(action => - { - if (action.IsCompletedSuccessfully) - { - OnComplete(new AsyncResult - { - IsCompleted = true, - AsyncState = (true, action.Result.Value) - }); - } - else - { - OnComplete(new AsyncResult - { - IsCompleted = false, - AsyncState = (false, (string)null) - }); - } - }); - }).Start(); - } - - private void BeginSetDvar(Server server, string dvarName, string dvarValue, Delegate onCompleted) - { - var operationTimeout = TimeSpan.FromSeconds(5); - - void OnComplete(IAsyncResult result) - { - try - { - _onProcessing.Wait(); - var success = (bool)result.AsyncState; - onCompleted.DynamicInvoke(JsValue.Undefined, - new[] - { - JsValue.FromObject(_scriptEngine, server), - JsValue.FromObject(_scriptEngine, dvarName), - JsValue.FromObject(_scriptEngine, dvarValue), - JsValue.FromObject(_scriptEngine, success) - }); - } - catch (JavaScriptException ex) - { - using (LogContext.PushProperty("Server", server.ToString())) - { - _logger.LogError(ex, "Could complete BeginSetDvar for {Filename} {@Location}", - Path.GetFileName(_fileName), ex.Location); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Could not complete {BeginSetDvar} for {Class}", nameof(BeginSetDvar), Name); - } - finally - { - if (_onProcessing.CurrentCount == 0) - { - _onProcessing.Release(1); - } - } - } - - new Thread(() => - { - if (DateTime.Now - (server.MatchEndTime ?? server.MatchStartTime) < TimeSpan.FromSeconds(15)) - { - using (LogContext.PushProperty("Server", server.ToString())) - { - _logger.LogDebug("Not setting DVar because match recently ended"); - } - - OnComplete(new AsyncResult - { - IsCompleted = false, - AsyncState = false - }); - } - - using var tokenSource = new CancellationTokenSource(); - tokenSource.CancelAfter(operationTimeout); - - server.SetDvarAsync(dvarName, dvarValue, token: tokenSource.Token).ContinueWith(action => - { - if (action.IsCompletedSuccessfully) - { - OnComplete(new AsyncResult - { - IsCompleted = true, - AsyncState = true - }); - } - else - { - OnComplete(new AsyncResult - { - IsCompleted = false, - AsyncState = false - }); - } - }); - }).Start(); - } - private T WrapJavaScriptErrorHandling(Func work, object additionalData = null, Server server = null, [CallerMemberName] string methodName = "") { diff --git a/Application/Plugin/Script/ScriptPluginConfigurationWrapper.cs b/Application/Plugin/Script/ScriptPluginConfigurationWrapper.cs new file mode 100644 index 000000000..1d0114bfe --- /dev/null +++ b/Application/Plugin/Script/ScriptPluginConfigurationWrapper.cs @@ -0,0 +1,93 @@ +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using IW4MAdmin.Application.Configuration; +using Jint; +using Jint.Native; +using SharedLibraryCore.Interfaces; + +namespace IW4MAdmin.Application.Plugin.Script; + +public class ScriptPluginConfigurationWrapper +{ + private readonly ScriptPluginConfiguration _config; + private readonly IConfigurationHandlerV2 _configHandler; + private readonly Engine _scriptEngine; + private string _pluginName; + + public ScriptPluginConfigurationWrapper(string pluginName, Engine scriptEngine, IConfigurationHandlerV2 configHandler) + { + _pluginName = pluginName; + _scriptEngine = scriptEngine; + _configHandler = configHandler; + _config = configHandler.Get("ScriptPluginSettings", new ScriptPluginConfiguration()).GetAwaiter().GetResult(); + } + + public void SetName(string name) + { + _pluginName = name; + } + + public async Task SetValue(string key, object value) + { + var castValue = value; + + if (value is double doubleValue) + { + castValue = AsInteger(doubleValue) ?? value; + } + + if (value is object[] array && array.All(item => item is double d && AsInteger(d) != null)) + { + castValue = array.Select(item => AsInteger((double)item)).ToArray(); + } + + if (!_config.ContainsKey(_pluginName)) + { + _config.Add(_pluginName, new Dictionary()); + } + + var plugin = _config[_pluginName]; + + if (plugin.ContainsKey(key)) + { + plugin[key] = castValue; + } + + else + { + plugin.Add(key, castValue); + } + + await _configHandler.Set(_config); + } + + public JsValue GetValue(string key) + { + if (!_config.ContainsKey(_pluginName)) + { + return JsValue.Undefined; + } + + if (!_config[_pluginName].ContainsKey(key)) + { + return JsValue.Undefined; + } + + var item = _config[_pluginName][key]; + + if (item is JsonElement { ValueKind: JsonValueKind.Array } jElem) + { + item = jElem.Deserialize>(); + } + + return JsValue.FromObject(_scriptEngine, item); + } + + private static int? AsInteger(double value) + { + return int.TryParse(value.ToString(CultureInfo.InvariantCulture), out var parsed) ? parsed : null; + } +} diff --git a/Application/Plugin/Script/ScriptPluginFactory.cs b/Application/Plugin/Script/ScriptPluginFactory.cs new file mode 100644 index 000000000..800403ebd --- /dev/null +++ b/Application/Plugin/Script/ScriptPluginFactory.cs @@ -0,0 +1,32 @@ +using System; +using IW4MAdmin.Application.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using SharedLibraryCore.Interfaces; + +namespace IW4MAdmin.Application.Plugin.Script; + +public class ScriptPluginFactory : IScriptPluginFactory +{ + private readonly IServiceProvider _serviceProvider; + + public ScriptPluginFactory(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + public object CreateScriptPlugin(Type type, string fileName) + { + if (type == typeof(IPlugin)) + { + return new ScriptPlugin(_serviceProvider.GetRequiredService>(), + fileName); + } + + return new ScriptPluginV2(fileName, _serviceProvider.GetRequiredService>(), + _serviceProvider.GetRequiredService(), + _serviceProvider.GetRequiredService(), + _serviceProvider.GetRequiredService>(), + _serviceProvider.GetRequiredService()); + } +} diff --git a/Application/Plugin/Script/ScriptPluginHelper.cs b/Application/Plugin/Script/ScriptPluginHelper.cs new file mode 100644 index 000000000..4138a9293 --- /dev/null +++ b/Application/Plugin/Script/ScriptPluginHelper.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Jint.Native; +using SharedLibraryCore.Interfaces; + +namespace IW4MAdmin.Application.Plugin.Script; + +public class ScriptPluginHelper +{ + private readonly IManager _manager; + private readonly ScriptPluginV2 _scriptPlugin; + private readonly SemaphoreSlim _onRequestRunning = new(1, 5); + private const int RequestTimeout = 500; + + public ScriptPluginHelper(IManager manager, ScriptPluginV2 scriptPlugin) + { + _manager = manager; + _scriptPlugin = scriptPlugin; + } + + public void GetUrl(string url, Delegate callback) + { + RequestUrl(new ScriptPluginWebRequest(url), callback); + } + + public void GetUrl(string url, Dictionary headers, Delegate callback) + { + RequestUrl(new ScriptPluginWebRequest(url, Headers: headers), callback); + } + + public void PostUrl(string url, Dictionary headers, Delegate callback) + { + RequestUrl(new ScriptPluginWebRequest(url, null, "POST", Headers: headers), callback); + } + + public void RequestUrl(ScriptPluginWebRequest request, Delegate callback) + { + Task.Run(() => + { + try + { + var response = RequestInternal(request); + _scriptPlugin.ExecuteWithErrorHandling(scriptEngine => + { + callback.DynamicInvoke(JsValue.Undefined, new[] { JsValue.FromObject(scriptEngine, response) }); + }); + } + catch + { + // ignored + } + }); + } + + public void RequestNotify(int delayMs, Delegate callback) + { + Task.Run(async () => + { + try + { + await Task.Delay(delayMs, _manager.CancellationToken); + _scriptPlugin.ExecuteWithErrorHandling(_ => callback.DynamicInvoke(JsValue.Undefined)); + } + catch + { + // ignored + } + }); + } + + private object RequestInternal(ScriptPluginWebRequest request) + { + var entered = false; + using var tokenSource = new CancellationTokenSource(RequestTimeout); + + using var client = new HttpClient(); + + try + { + _onRequestRunning.Wait(tokenSource.Token); + + entered = true; + var requestMessage = new HttpRequestMessage(new HttpMethod(request.Method), request.Url); + + if (request.Body is not null) + { + requestMessage.Content = new StringContent(request.Body.ToString() ?? string.Empty, Encoding.Default, + request.ContentType ?? "text/plain"); + } + + if (request.Headers is not null) + { + foreach (var (key, value) in request.Headers) + { + if (!string.IsNullOrWhiteSpace(key)) + { + requestMessage.Headers.Add(key, value); + } + } + } + + var response = client.Send(requestMessage, tokenSource.Token); + using var reader = new StreamReader(response.Content.ReadAsStream()); + return reader.ReadToEnd(); + } + catch (HttpRequestException ex) + { + return new + { + ex.StatusCode, + ex.Message, + IsError = true + }; + } + catch (Exception ex) + { + return new + { + ex.Message, + IsError = true + }; + } + finally + { + if (entered) + { + _onRequestRunning.Release(1); + } + } + } +} diff --git a/Application/Misc/ScriptPluginServiceResolver.cs b/Application/Plugin/Script/ScriptPluginServiceResolver.cs similarity index 76% rename from Application/Misc/ScriptPluginServiceResolver.cs rename to Application/Plugin/Script/ScriptPluginServiceResolver.cs index 552bbe532..8f4cc51b8 100644 --- a/Application/Misc/ScriptPluginServiceResolver.cs +++ b/Application/Plugin/Script/ScriptPluginServiceResolver.cs @@ -1,8 +1,8 @@ -using SharedLibraryCore.Interfaces; -using System; +using System; using System.Linq; +using SharedLibraryCore.Interfaces; -namespace IW4MAdmin.Application.Misc +namespace IW4MAdmin.Application.Plugin.Script { /// /// implementation of IScriptPluginServiceResolver @@ -25,7 +25,7 @@ namespace IW4MAdmin.Application.Misc public object ResolveService(string serviceName, string[] genericParameters) { var serviceType = DetermineRootType(serviceName, genericParameters.Length); - var genericTypes = genericParameters.Select(_genericTypeParam => DetermineRootType(_genericTypeParam)); + var genericTypes = genericParameters.Select(genericTypeParam => DetermineRootType(genericTypeParam)); var resolvedServiceType = serviceType.MakeGenericType(genericTypes.ToArray()); return _serviceProvider.GetService(resolvedServiceType); } @@ -34,8 +34,8 @@ namespace IW4MAdmin.Application.Misc { var typeCollection = AppDomain.CurrentDomain.GetAssemblies() .SelectMany(t => t.GetTypes()); - string generatedName = $"{serviceName}{(genericParamCount == 0 ? "" : $"`{genericParamCount}")}".ToLower(); - var serviceType = typeCollection.FirstOrDefault(_type => _type.Name.ToLower() == generatedName); + var generatedName = $"{serviceName}{(genericParamCount == 0 ? "" : $"`{genericParamCount}")}".ToLower(); + var serviceType = typeCollection.FirstOrDefault(type => type.Name.ToLower() == generatedName); if (serviceType == null) { diff --git a/Application/Misc/ScriptPluginTimerHelper.cs b/Application/Plugin/Script/ScriptPluginTimerHelper.cs similarity index 97% rename from Application/Misc/ScriptPluginTimerHelper.cs rename to Application/Plugin/Script/ScriptPluginTimerHelper.cs index 116885b6d..e426d4ff6 100644 --- a/Application/Misc/ScriptPluginTimerHelper.cs +++ b/Application/Plugin/Script/ScriptPluginTimerHelper.cs @@ -6,8 +6,9 @@ using Microsoft.Extensions.Logging; using SharedLibraryCore.Interfaces; using ILogger = Microsoft.Extensions.Logging.ILogger; -namespace IW4MAdmin.Application.Misc; +namespace IW4MAdmin.Application.Plugin.Script; +[Obsolete("This architecture is superseded by the request notify delay architecture")] public class ScriptPluginTimerHelper : IScriptPluginTimerHelper { private Timer _timer; diff --git a/Application/Plugin/Script/ScriptPluginV2.cs b/Application/Plugin/Script/ScriptPluginV2.cs new file mode 100644 index 000000000..be797f68a --- /dev/null +++ b/Application/Plugin/Script/ScriptPluginV2.cs @@ -0,0 +1,568 @@ +using System; +using System.Collections.Generic; +using System.Dynamic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Data.Models; +using IW4MAdmin.Application.Configuration; +using IW4MAdmin.Application.Extensions; +using Jint; +using Jint.Native; +using Jint.Runtime; +using Jint.Runtime.Interop; +using Microsoft.Extensions.Logging; +using Serilog.Context; +using SharedLibraryCore; +using SharedLibraryCore.Commands; +using SharedLibraryCore.Database.Models; +using SharedLibraryCore.Events.Server; +using SharedLibraryCore.Exceptions; +using SharedLibraryCore.Interfaces; +using SharedLibraryCore.Interfaces.Events; +using ILogger = Microsoft.Extensions.Logging.ILogger; +using JavascriptEngine = Jint.Engine; + +namespace IW4MAdmin.Application.Plugin.Script; + +public class ScriptPluginV2 : IPluginV2 +{ + public string Name { get; private set; } = string.Empty; + public string Author { get; private set; } = string.Empty; + public string Version { get; private set; } + + private readonly string _fileName; + private readonly ILogger _logger; + private readonly IScriptPluginServiceResolver _pluginServiceResolver; + private readonly IScriptCommandFactory _scriptCommandFactory; + private readonly IConfigurationHandlerV2 _configHandler; + private readonly IInteractionRegistration _interactionRegistration; + private readonly SemaphoreSlim _onProcessingScript = new(1, 1); + private readonly SemaphoreSlim _onLoadingFile = new(1, 1); + private readonly FileSystemWatcher _scriptWatcher; + private readonly List _registeredCommandNames = new(); + private readonly List _registeredInteractions = new(); + private readonly Dictionary> _registeredEvents = new(); + private bool _firstInitialization = true; + + private record ScriptPluginDetails(string Name, string Author, string Version, + ScriptPluginCommandDetails[] Commands, ScriptPluginInteractionDetails[] Interactions); + + private record ScriptPluginCommandDetails(string Name, string Description, string Alias, string Permission, + bool TargetRequired, CommandArgument[] Arguments, IEnumerable SupportedGames, Delegate Execute); + + private JavascriptEngine ScriptEngine + { + get + { + lock (ActiveEngines) + { + return ActiveEngines[$"{GetHashCode()}-{_nextEngineId}"]; + } + } + } + + private record ScriptPluginInteractionDetails(string Name, Delegate Action); + + private ScriptPluginConfigurationWrapper _scriptPluginConfigurationWrapper; + private int _nextEngineId; + private static readonly Dictionary ActiveEngines = new(); + + public ScriptPluginV2(string fileName, ILogger logger, + IScriptPluginServiceResolver pluginServiceResolver, IScriptCommandFactory scriptCommandFactory, + IConfigurationHandlerV2 configHandler, + IInteractionRegistration interactionRegistration) + { + _fileName = fileName; + _logger = logger; + _pluginServiceResolver = pluginServiceResolver; + _scriptCommandFactory = scriptCommandFactory; + _configHandler = configHandler; + _interactionRegistration = interactionRegistration; + _scriptWatcher = new FileSystemWatcher + { + Path = Path.Join(Utilities.OperatingDirectory, "Plugins"), + NotifyFilter = NotifyFilters.LastWrite, + Filter = _fileName.Split(Path.DirectorySeparatorChar).Last() + }; + + IManagementEventSubscriptions.Load += OnLoad; + } + + public void ExecuteWithErrorHandling(Action work) + { + WrapJavaScriptErrorHandling(() => + { + work(ScriptEngine); + return true; + }, _logger, _fileName, _onProcessingScript); + } + + public object QueryWithErrorHandling(Delegate action, params object[] args) + { + return WrapJavaScriptErrorHandling(() => + { + var jsArgs = args?.Select(param => JsValue.FromObject(ScriptEngine, param)).ToArray(); + var result = action.DynamicInvoke(JsValue.Undefined, jsArgs); + return result; + }, _logger, _fileName, _onProcessingScript); + } + + private async Task OnLoad(IManager manager, CancellationToken token) + { + var entered = false; + try + { + await _onLoadingFile.WaitAsync(token); + entered = true; + + _logger.LogDebug("{Method} executing for {Plugin}", nameof(OnLoad), _fileName); + + if (new FileInfo(_fileName).Length == 0L) + { + return; + } + + _scriptWatcher.EnableRaisingEvents = false; + + UnregisterScriptEntities(manager); + ResetEngineState(); + + if (_firstInitialization) + { + _scriptWatcher.Changed += async (_, _) => await OnLoad(manager, token); + _firstInitialization = false; + } + + await using var stream = + new FileStream(_fileName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var reader = new StreamReader(stream, Encoding.Default); + var pluginScript = await reader.ReadToEndAsync(); + + var pluginDetails = WrapJavaScriptErrorHandling(() => + { + if (IsEngineDisposed(GetHashCode(), _nextEngineId)) + { + return null; + } + + ScriptEngine.Execute(pluginScript); + var initResult = ScriptEngine.Call("init", JsValue.FromObject(ScriptEngine, EventCallbackWrapper), + JsValue.FromObject(ScriptEngine, _pluginServiceResolver), + JsValue.FromObject(ScriptEngine, _scriptPluginConfigurationWrapper), + JsValue.FromObject(ScriptEngine, new ScriptPluginHelper(manager, this))); + + if (initResult.IsNull() || initResult.IsUndefined()) + { + return null; + } + + return AsScriptPluginInstance(initResult.ToObject()); + }, _logger, _fileName, _onProcessingScript); + + if (pluginDetails is null) + { + _logger.LogInformation("No valid script plugin signature found for {FilePath}", _fileName); + return; + } + + foreach (var command in pluginDetails.Commands) + { + RegisterCommand(manager, command); + + _logger.LogDebug("Registered script plugin command {Command} for {Plugin}", command.Name, + pluginDetails.Name); + } + + foreach (var interaction in pluginDetails.Interactions) + { + RegisterInteraction(interaction); + + _logger.LogDebug("Registered script plugin interaction {Interaction} for {Plugin}", interaction.Name, + pluginDetails.Name); + } + + Name = pluginDetails.Name; + Author = pluginDetails.Author; + Version = pluginDetails.Version; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error encountered loading script plugin {Name}", _fileName); + } + finally + { + if (entered) + { + _onLoadingFile.Release(1); + _scriptWatcher.EnableRaisingEvents = true; + } + + _logger.LogDebug("{Method} completed for {Plugin}", nameof(OnLoad), _fileName); + } + } + + private void RegisterInteraction(ScriptPluginInteractionDetails interaction) + { + Task Action(int? targetId, Reference.Game? game, CancellationToken token) => + WrapJavaScriptErrorHandling(() => + { + if (IsEngineDisposed(GetHashCode(), _nextEngineId)) + { + return null; + } + + var args = new object[] { targetId, game, token }.Select(arg => JsValue.FromObject(ScriptEngine, arg)) + .ToArray(); + + if (interaction.Action.DynamicInvoke(JsValue.Undefined, args) is not ObjectWrapper result) + { + throw new PluginException("Invalid interaction object returned"); + } + + return Task.FromResult((IInteractionData)result.ToObject()); + }, _logger, _fileName, _onProcessingScript); + + _interactionRegistration.RegisterInteraction(interaction.Name, Action); + _registeredInteractions.Add(interaction.Name); + } + + private void RegisterCommand(IManager manager, ScriptPluginCommandDetails command) + { + Task Execute(GameEvent gameEvent) => + WrapJavaScriptErrorHandling(() => + { + if (IsEngineDisposed(GetHashCode(), _nextEngineId)) + { + return null; + } + + command.Execute.DynamicInvoke(JsValue.Undefined, + new[] { JsValue.FromObject(ScriptEngine, gameEvent) }); + return Task.CompletedTask; + }, _logger, _fileName, _onProcessingScript); + + var scriptCommand = _scriptCommandFactory.CreateScriptCommand(command.Name, command.Alias, + command.Description, + command.Permission, command.TargetRequired, + command.Arguments, Execute, command.SupportedGames); + + manager.AddAdditionalCommand(scriptCommand); + _registeredCommandNames.Add(scriptCommand.Name); + } + + private void ResetEngineState() + { + JavascriptEngine oldEngine = null; + + lock (ActiveEngines) + { + if (ActiveEngines.ContainsKey($"{GetHashCode()}-{_nextEngineId}")) + { + oldEngine = ActiveEngines[$"{GetHashCode()}-{_nextEngineId}"]; + _logger.LogDebug("Removing script engine from active list {HashCode}", _nextEngineId); + ActiveEngines.Remove($"{GetHashCode()}-{_nextEngineId}"); + } + } + + Interlocked.Increment(ref _nextEngineId); + oldEngine?.Dispose(); + var newEngine = new JavascriptEngine(cfg => + cfg.AddExtensionMethods(typeof(Utilities), typeof(Enumerable), typeof(Queryable), + typeof(ScriptPluginExtensions), typeof(LoggerExtensions)) + .AllowClr(typeof(System.Net.Http.HttpClient).Assembly, typeof(EFClient).Assembly, + typeof(Utilities).Assembly, typeof(Encoding).Assembly, typeof(CancellationTokenSource).Assembly, + typeof(Data.Models.Client.EFClient).Assembly, typeof(IW4MAdmin.Plugins.Stats.Plugin).Assembly) + .CatchClrExceptions() + .AddObjectConverter(new EnumsToStringConverter())); + + lock (ActiveEngines) + { + _logger.LogDebug("Adding script engine to active list {HashCode}", _nextEngineId); + ActiveEngines.Add($"{GetHashCode()}-{_nextEngineId}", newEngine); + } + + _scriptPluginConfigurationWrapper = + new ScriptPluginConfigurationWrapper(_fileName.Split(Path.DirectorySeparatorChar).Last(), ScriptEngine, + _configHandler); + } + + private void UnregisterScriptEntities(IManager manager) + { + foreach (var commandName in _registeredCommandNames) + { + manager.RemoveCommandByName(commandName); + _logger.LogDebug("Unregistered script plugin command {Command} for {Plugin}", commandName, Name); + } + + _registeredCommandNames.Clear(); + + foreach (var interactionName in _registeredInteractions) + { + _interactionRegistration.UnregisterInteraction(interactionName); + } + + _registeredInteractions.Clear(); + + foreach (var (removeMethod, subscriptions) in _registeredEvents) + { + foreach (var subscription in subscriptions) + { + removeMethod.Invoke(null, new[] { subscription }); + } + + subscriptions.Clear(); + } + + _registeredEvents.Clear(); + } + + private void EventCallbackWrapper(string eventCallbackName, Delegate javascriptAction) + { + var eventCategory = eventCallbackName.Split(".")[0]; + + var eventCategoryType = eventCategory switch + { + nameof(IManagementEventSubscriptions) => typeof(IManagementEventSubscriptions), + nameof(IGameEventSubscriptions) => typeof(IGameEventSubscriptions), + nameof(IGameServerEventSubscriptions) => typeof(IGameServerEventSubscriptions), + _ => null + }; + + if (eventCategoryType is null) + { + _logger.LogWarning("{EventCategory} is not a valid subscription category", eventCategory); + return; + } + + var eventName = eventCallbackName.Split(".")[1]; + var eventAddMethod = eventCategoryType.GetMethods() + .FirstOrDefault(method => method.Name.StartsWith($"add_{eventName}")); + var eventRemoveMethod = eventCategoryType.GetMethods() + .FirstOrDefault(method => method.Name.StartsWith($"remove_{eventName}")); + + if (eventAddMethod is null || eventRemoveMethod is null) + { + _logger.LogWarning("{EventName} is not a valid subscription event", eventName); + return; + } + + var genericType = eventAddMethod.GetParameters()[0].ParameterType.GetGenericArguments()[0]; + + var eventWrapper = + typeof(ScriptPluginV2).GetMethod(nameof(BuildEventWrapper), BindingFlags.Static | BindingFlags.NonPublic)! + .MakeGenericMethod(genericType) + .Invoke(null, + new object[] + { _logger, _fileName, javascriptAction, GetHashCode(), _nextEngineId, _onProcessingScript }); + + eventAddMethod.Invoke(null, new[] { eventWrapper }); + + if (!_registeredEvents.ContainsKey(eventRemoveMethod)) + { + _registeredEvents.Add(eventRemoveMethod, new List { eventWrapper }); + } + else + { + _registeredEvents[eventRemoveMethod].Add(eventWrapper); + } + } + + private static Func BuildEventWrapper(ILogger logger, + string fileName, Delegate javascriptAction, int hashCode, int engineId, SemaphoreSlim onProcessingScript) + { + return (coreEvent, token) => + { + return WrapJavaScriptErrorHandling(() => + { + if (IsEngineDisposed(hashCode, engineId)) + { + return Task.CompletedTask; + } + + JavascriptEngine engine; + + lock (ActiveEngines) + { + engine = ActiveEngines[$"{hashCode}-{engineId}"]; + } + + var args = new object[] { coreEvent, token } + .Select(param => JsValue.FromObject(engine, param)) + .ToArray(); + javascriptAction.DynamicInvoke(JsValue.Undefined, args); + return Task.CompletedTask; + }, logger, fileName, onProcessingScript, (coreEvent as GameServerEvent)?.Server, + additionalData: coreEvent.GetType().Name); + }; + } + + private static bool IsEngineDisposed(int hashCode, int engineId) + { + lock (ActiveEngines) + { + return !ActiveEngines.ContainsKey($"{hashCode}-{engineId}"); + } + } + + private static TResultType WrapJavaScriptErrorHandling(Func work, ILogger logger, + string fileName, SemaphoreSlim onProcessingScript, IGameServer server = null, object additionalData = null, + bool throwException = false, + [CallerMemberName] string methodName = "") + { + using (LogContext.PushProperty("Server", server?.Id)) + { + var waitCompleted = false; + try + { + onProcessingScript.Wait(); + waitCompleted = true; + return work(); + } + catch (JavaScriptException ex) + { + logger.LogError(ex, + "Encountered JavaScript runtime error while executing {MethodName} for script plugin {Plugin} at {@LocationInfo} StackTrace={StackTrace} {@AdditionalData}", + methodName, Path.GetFileName(fileName), ex.Location, ex.StackTrace, additionalData); + + if (throwException) + { + throw new PluginException("A runtime error occured while executing action for script plugin"); + } + } + catch (Exception ex) when (ex.InnerException is JavaScriptException jsEx) + { + logger.LogError(ex, + "Encountered JavaScript runtime error while executing {MethodName} for script plugin {Plugin} initialization {@LocationInfo} StackTrace={StackTrace} {@AdditionalData}", + methodName, fileName, jsEx.Location, jsEx.JavaScriptStackTrace, additionalData); + + if (throwException) + { + throw new PluginException("A runtime error occured while executing action for script plugin"); + } + } + catch (Exception ex) + { + logger.LogError(ex, + "Encountered JavaScript runtime error while executing {MethodName} for script plugin {Plugin}", + methodName, Path.GetFileName(fileName)); + + if (throwException) + { + throw new PluginException("An error occured while executing action for script plugin"); + } + } + finally + { + if (waitCompleted) + { + onProcessingScript.Release(1); + } + } + } + + return default; + } + + private static ScriptPluginDetails AsScriptPluginInstance(dynamic source) + { + var commandDetails = Array.Empty(); + if (HasProperty(source, "commands") && source.commands is dynamic[]) + { + commandDetails = ((dynamic[])source.commands).Select(command => + { + var commandArgs = Array.Empty(); + if (HasProperty(command, "arguments") && command.arguments is dynamic[]) + { + commandArgs = ((dynamic[])command.arguments).Select(argument => new CommandArgument + { + Name = HasProperty(argument, "name") ? argument.name : string.Empty, + Required = HasProperty(argument, "required") && argument.required is bool && + (bool)argument.required + }).ToArray(); + } + + var name = HasProperty(command, "name") && command.name is string + ? (string)command.name + : string.Empty; + var description = HasProperty(command, "description") && command.description is string + ? (string)command.description + : string.Empty; + var alias = HasProperty(command, "alias") && command.alias is string + ? (string)command.alias + : string.Empty; + var permission = HasProperty(command, "permission") && command.permission is string + ? (string)command.permission + : string.Empty; + var isTargetRequired = HasProperty(command, "targetRequired") && command.targetRequired is bool && + (bool)command.targetRequired; + var supportedGames = + HasProperty(command, "supportedGames") && command.supportedGames is IEnumerable + ? ((IEnumerable)command.supportedGames).Where(game => game?.ToString() is not null) + .Select(game => + Enum.Parse(game.ToString()!)) + : Array.Empty(); + var execute = HasProperty(command, "execute") && command.execute is Delegate + ? (Delegate)command.execute + : (GameEvent _) => Task.CompletedTask; + + return new ScriptPluginCommandDetails(name, description, alias, permission, isTargetRequired, + commandArgs, supportedGames, execute); + + }).ToArray(); + } + + var interactionDetails = Array.Empty(); + if (HasProperty(source, "interactions") && source.interactions is dynamic[]) + { + interactionDetails = ((dynamic[])source.interactions).Select(interaction => + { + var name = HasProperty(interaction, "name") && interaction.name is string + ? (string)interaction.name + : string.Empty; + var action = HasProperty(interaction, "action") && interaction.action is Delegate + ? (Delegate)interaction.action + : null; + + return new ScriptPluginInteractionDetails(name, action); + }).ToArray(); + } + + var name = HasProperty(source, "name") && source.name is string ? (string)source.name : string.Empty; + var author = HasProperty(source, "author") && source.author is string ? (string)source.author : string.Empty; + var version = HasProperty(source, "version") && source.version is string ? (string)source.author : string.Empty; + + return new ScriptPluginDetails(name, author, version, commandDetails, interactionDetails); + } + + private static bool HasProperty(dynamic source, string name) + { + Type objType = source.GetType(); + + if (objType == typeof(ExpandoObject)) + { + return ((IDictionary)source).ContainsKey(name); + } + + return objType.GetProperty(name) != null; + } + + public class EnumsToStringConverter : IObjectConverter + { + public bool TryConvert(Engine engine, object value, out JsValue result) + { + if (value is Enum) + { + result = value.ToString(); + return true; + } + + result = JsValue.Null; + return false; + } + } +} diff --git a/Application/Plugin/Script/ScriptPluginWebRequest.cs b/Application/Plugin/Script/ScriptPluginWebRequest.cs new file mode 100644 index 000000000..6be2a4b1e --- /dev/null +++ b/Application/Plugin/Script/ScriptPluginWebRequest.cs @@ -0,0 +1,6 @@ +using System.Collections.Generic; + +namespace IW4MAdmin.Application.Plugin.Script; + +public record ScriptPluginWebRequest(string Url, object Body = null, string Method = "GET", string ContentType = "text/plain", + Dictionary Headers = null); diff --git a/Plugins/ScriptPlugins/ActionOnReport.js b/Plugins/ScriptPlugins/ActionOnReport.js index 76aec4f38..24ca455ad 100644 --- a/Plugins/ScriptPlugins/ActionOnReport.js +++ b/Plugins/ScriptPlugins/ActionOnReport.js @@ -1,7 +1,7 @@ const init = (registerEventCallback, serviceResolver, _) => { plugin.onLoad(serviceResolver); - registerEventCallback('IManagementEventSubscriptions.ClientPenaltyAdministered', (penaltyEvent, token) => { + registerEventCallback('IManagementEventSubscriptions.ClientPenaltyAdministered', (penaltyEvent, _) => { plugin.onPenalty(penaltyEvent); }); @@ -10,7 +10,7 @@ const init = (registerEventCallback, serviceResolver, _) => { const plugin = { author: 'RaidMax', - version: '1.2', + version: '2.0', name: 'Action on Report', enabled: false, // indicates if the plugin is enabled reportAction: 'TempBan', // can be TempBan or Ban @@ -20,7 +20,7 @@ const plugin = { 'report': 0 }, - onPenalty: function(penaltyEvent) { + onPenalty: function (penaltyEvent) { if (!this.enabled || penaltyEvent.penalty.type !== this.penaltyType['report']) { return; } @@ -48,7 +48,7 @@ const plugin = { } }, - onLoad: function(serviceResolver) { + onLoad: function (serviceResolver) { this.translations = serviceResolver.resolveService('ITranslationLookup'); this.logger = serviceResolver.resolveService('ILogger', ['ScriptPluginV2']); this.logger.logInformation('ActionOnReport {version} by {author} loaded. Enabled={enabled}', this.version, this.author, this.enabled); diff --git a/Plugins/ScriptPlugins/BanBroadcasting.js b/Plugins/ScriptPlugins/BanBroadcasting.js index 8f82e76c0..c18e49be9 100644 --- a/Plugins/ScriptPlugins/BanBroadcasting.js +++ b/Plugins/ScriptPlugins/BanBroadcasting.js @@ -1,48 +1,60 @@ -const broadcastMessage = (server, message) => { - server.Manager.GetServers().forEach(s => { - s.Broadcast(message); - }); +const init = (registerNotify, serviceResolver, config) => { + registerNotify('IManagementEventSubscriptions.ClientPenaltyAdministered', (penaltyEvent, _) => plugin.onClientPenalty(penaltyEvent)); + + plugin.onLoad(serviceResolver, config); + return plugin; }; const plugin = { - author: 'Amos', - version: 1.0, + author: 'Amos, RaidMax', + version: '2.0', name: 'Broadcast Bans', + config: null, + logger: null, + translations: null, + manager: null, - onEventAsync: function (gameEvent, server) { + onClientPenalty: function (penaltyEvent) { if (!this.enableBroadcastBans) { return; } - if (gameEvent.TypeName === 'Ban') { - let penalty = undefined; - gameEvent.Origin.AdministeredPenalties?.forEach(p => { - penalty = p.AutomatedOffense; - }) + let automatedPenaltyMessage; - if (gameEvent.Origin.ClientId === 1 && penalty !== undefined) { - let localization = _localization.LocalizationIndex['PLUGINS_BROADCAST_BAN_ACMESSAGE'].replace('{{targetClient}}', gameEvent.Target.CleanedName); - broadcastMessage(server, localization); - } else { - let localization = _localization.LocalizationIndex['PLUGINS_BROADCAST_BAN_MESSAGE'].replace('{{targetClient}}', gameEvent.Target.CleanedName); - broadcastMessage(server, localization); - } + penaltyEvent.penalty.punisher.administeredPenalties?.forEach(penalty => { + automatedPenaltyMessage = penalty.automatedOffense; + }); + + if (penaltyEvent.penalty.punisher.clientId === 1 && automatedPenaltyMessage !== undefined) { + let message = this.translations['PLUGINS_BROADCAST_BAN_ACMESSAGE'].replace('{{targetClient}}', penaltyEvent.client.cleanedName); + this.broadcastMessage(message); + } else { + let message = this.translations['PLUGINS_BROADCAST_BAN_MESSAGE'].replace('{{targetClient}}', penaltyEvent.client.cleanedName); + this.broadcastMessage(message); } }, - onLoadAsync: function (manager) { - this.configHandler = _configHandler; - this.enableBroadcastBans = this.configHandler.GetValue('EnableBroadcastBans'); + broadcastMessage: function (message) { + this.manager.getServers().forEach(server => { + server.broadcast(message); + }); + }, + + onLoad: function (serviceResolver, config) { + this.config = config; + this.config.setName(this.name); + this.enableBroadcastBans = this.config.getValue('EnableBroadcastBans'); + + this.manager = serviceResolver.resolveService('IManager'); + this.logger = serviceResolver.resolveService('ILogger', ['ScriptPluginV2']); + this.translations = serviceResolver.resolveService('ITranslationLookup'); if (this.enableBroadcastBans === undefined) { this.enableBroadcastBans = false; - this.configHandler.SetValue('EnableBroadcastBans', this.enableBroadcastBans); + this.config.setValue('EnableBroadcastBans', this.enableBroadcastBans); } - }, - onUnloadAsync: function () { - }, - - onTickAsync: function (server) { + this.logger.logInformation('{Name} {Version} by {Author} loaded. Enabled={enabled}', this.name, this.version, + this.author, this.enableBroadcastBans); } }; diff --git a/Plugins/ScriptPlugins/GameInterface.js b/Plugins/ScriptPlugins/GameInterface.js index 33a6764b8..44c7de553 100644 --- a/Plugins/ScriptPlugins/GameInterface.js +++ b/Plugins/ScriptPlugins/GameInterface.js @@ -1,98 +1,400 @@ const servers = {}; const inDvar = 'sv_iw4madmin_in'; const outDvar = 'sv_iw4madmin_out'; -const pollRate = 900; -const enableCheckTimeout = 10000; -let logger = {}; -const maxQueuedMessages = 25; +const integrationEnabledDvar = 'sv_iw4madmin_integration_enabled'; +const pollingRate = 300; -let plugin = { +const init = (registerNotify, serviceResolver, config) => { + registerNotify('IManagementEventSubscriptions.ClientStateInitialized', (clientEvent, _) => plugin.onClientEnteredMatch(clientEvent)); + registerNotify('IGameServerEventSubscriptions.ServerValueReceived', (serverValueEvent, _) => plugin.onServerValueReceived(serverValueEvent)); + registerNotify('IGameServerEventSubscriptions.ServerValueSetCompleted', (serverValueEvent, _) => plugin.onServerValueSetCompleted(serverValueEvent)); + registerNotify('IGameServerEventSubscriptions.MonitoringStarted', (monitorStartEvent, _) => plugin.onServerMonitoringStart(monitorStartEvent)); + registerNotify('IManagementEventSubscriptions.ClientPenaltyAdministered', (penaltyEvent, _) => plugin.onPenalty(penaltyEvent)); + + plugin.onLoad(serviceResolver, config); + return plugin; +}; + +const plugin = { author: 'RaidMax', - version: 1.1, + version: '2.0', name: 'Game Interface', + serviceResolver: null, + eventManager: null, + logger: null, + commands: null, - onEventAsync: (gameEvent, server) => { - if (servers[server.EndPoint] != null && !servers[server.EndPoint].enabled) { - return; - } + onLoad: function (serviceResolver, config) { + this.serviceResolver = serviceResolver; + this.eventManager = serviceResolver.resolveService('IManager'); + this.logger = serviceResolver.resolveService('ILogger', ['ScriptPluginV2']); + this.commands = commands; + this.config = config; + }, - const eventType = String(gameEvent.TypeName).toLowerCase(); + onClientEnteredMatch: function (clientEvent) { + const serverState = servers[clientEvent.client.currentServer.id]; - if (eventType === undefined) { - return; - } - - switch (eventType) { - case 'start': - const enabled = initialize(server); - - if (!enabled) { - return; - } - break; - case 'preconnect': - // when the plugin is reloaded after the servers are started - if (servers[server.EndPoint] === undefined || servers[server.EndPoint] == null) { - const enabled = initialize(server); - - if (!enabled) { - return; - } - } - const timer = servers[server.EndPoint].timer; - if (!timer.IsRunning) { - timer.Start(0, pollRate); - } - break; - case 'warn': - const warningTitle = _localization.LocalizationIndex['GLOBAL_WARNING']; - sendScriptCommand(server, 'Alert', gameEvent.Origin, gameEvent.Target, { - alertType: warningTitle + '!', - message: gameEvent.Data - }); - break; + if (serverState === undefined || serverState == null) { + this.initializeServer(clientEvent.client.currentServer); + } else if (!serverState.running && !serverState.initializationInProgress) { + serverState.running = true; + this.requestGetDvar(inDvar, clientEvent.client.currentServer); } }, - onLoadAsync: manager => { - logger = _serviceResolver.ResolveService('ILogger'); - logger.WriteInfo('Game Interface Startup'); + onPenalty: function (penaltyEvent) { + const warning = 1; + if (penaltyEvent.penalty.type !== warning || !penaltyEvent.client.isIngame) { + return; + } + + sendScriptCommand(penaltyEvent.client.currentServer, 'Alert', penaltyEvent.penalty.punisher, penaltyEvent.client, { + alertType: this.translations('GLOBAL_WARNING') + '!', + message: penaltyEvent.penalty.offense + }); }, - onUnloadAsync: () => { - for (let i = 0; i < servers.length; i++) { - if (servers[i].enabled) { - servers[i].timer.Stop(); + onServerValueReceived: function (serverValueEvent) { + const name = serverValueEvent.response.name; + if (name === integrationEnabledDvar) { + this.handleInitializeServerData(serverValueEvent); + } else if (name === inDvar) { + this.handleIncomingServerData(serverValueEvent); + } + }, + + onServerValueSetCompleted: async function (serverValueEvent) { + if (serverValueEvent.valueName !== inDvar && serverValueEvent.valueName !== outDvar) { + this.logger.logDebug('Ignoring set complete of {name}', serverValueEvent.valueName); + return; + } + + const serverState = servers[serverValueEvent.server.id]; + serverState.outQueue.shift(); + + this.logger.logDebug('outQueue len = {outLen}, inQueue len = {inLen}', serverState.outQueue.length, serverState.inQueue.length); + + // if it didn't succeed, we need to retry + if (!serverValueEvent.success && !this.eventManager.cancellationToken.isCancellationRequested) { + this.logger.logDebug('Set of server value failed... retrying'); + this.requestSetDvar(serverValueEvent.valueName, serverValueEvent.value, serverValueEvent.server); + return; + } + + // we informed the server that we received the event + if (serverState.inQueue.length > 0 && serverValueEvent.valueName === inDvar) { + const input = serverState.inQueue.shift(); + + // if we queued an event then the next loop will be at the value set complete + if (await this.processEventMessage(input, serverValueEvent.server)) { + // return; } } + + this.logger.logDebug('loop complete'); + // loop restarts + this.requestGetDvar(inDvar, serverValueEvent.server); }, - onTickAsync: server => { + initializeServer: function (server) { + servers[server.id] = { + enabled: false, + running: false, + initializationInProgress: true, + queuedMessages: [], + inQueue: [], + outQueue: [], + commandQueue: [] + }; + + this.logger.logDebug('Initializing game interface for {serverId}', server.id); + this.requestGetDvar(integrationEnabledDvar, server); + }, + + handleInitializeServerData: function (responseEvent) { + this.logger.logInformation('GSC integration enabled = {integrationValue} for {server}', + responseEvent.response.value, responseEvent.server.id); + + if (responseEvent.response.value !== '1') { + return; + } + + const serverState = servers[responseEvent.server.id]; + serverState.outQueue.shift(); + serverState.enabled = true; + serverState.running = true; + serverState.initializationInProgress = false; + + this.requestGetDvar(inDvar, responseEvent.server); + }, + + handleIncomingServerData: function (responseEvent) { + this.logger.logDebug('Received {dvarName}={dvarValue} success={success} from {server}', responseEvent.response.name, + responseEvent.response.value, responseEvent.success, responseEvent.server.id); + + const serverState = servers[responseEvent.server.id]; + serverState.outQueue.shift(); + + if (responseEvent.server.connectedClients.count === 0) { + // no clients connected so we don't need to query + serverState.running = false; + return; + } + + // read failed, so let's retry + if (!responseEvent.success && !this.eventManager.cancellationToken.isCancellationRequested) { + this.logger.logDebug('Get of server value failed... retrying'); + this.requestGetDvar(responseEvent.response.name, responseEvent.server); + return; + } + + let input = responseEvent.response.value; + const server = responseEvent.server; + + if (this.eventManager.cancellationToken.isCancellationRequested) { + return; + } + + // no data available so we poll again or send any outgoing messages + if (isEmpty(input)) { + this.logger.logDebug('No data to process from server'); + if (serverState.commandQueue.length > 0) { + this.logger.logDebug('Sending next out message'); + const nextMessage = serverState.commandQueue.shift(); + this.requestSetDvar(outDvar, nextMessage, server); + } else { + this.requestGetDvar(inDvar, server); + } + return; + } + + serverState.inQueue.push(input); + + // let server know that we received the data + this.requestSetDvar(inDvar, '', server); + }, + + processEventMessage: async function (input, server) { + let messageQueued = false; + const event = parseEvent(input); + + this.logger.logDebug('Processing input... {eventType} {subType} {data} {clientNumber}', event.eventType, + event.subType, event.data.toString(), event.clientNumber); + + const metaService = this.serviceResolver.ResolveService('IMetaServiceV2'); + const threading = importNamespace('System.Threading'); + const tokenSource = new threading.CancellationTokenSource(); + const token = tokenSource.token; + + // todo: refactor to mapping if possible + if (event.eventType === 'ClientDataRequested') { + const client = server.getClientByNumber(event.clientNumber); + + if (client != null) { + this.logger.logDebug('Found client {name}', client.name); + + let data = []; + + const metaService = this.serviceResolver.ResolveService('IMetaServiceV2'); + + if (event.subType === 'Meta') { + const meta = (await metaService.getPersistentMeta(event.data, client.clientId, token)).result; + data[event.data] = meta === null ? '' : meta.Value; + this.logger.logDebug('event data is {data}', event.data); + } else { + const clientStats = getClientStats(client, server); + const tagMeta = (await metaService.getPersistentMetaByLookup('ClientTagV2', 'ClientTagNameV2', client.clientId, token)).result; + data = { + level: client.level, + clientId: client.clientId, + lastConnection: client.lastConnection, + tag: tagMeta?.value ?? '', + performance: clientStats?.performance ?? 200.0 + }; + } + + this.sendEventMessage(server, false, 'ClientDataReceived', event.subType, client, undefined, data); + messageQueued = true; + } else { + this.logger.logWarning('Could not find client slot {clientNumber} when processing {eventType}', event.clientNumber, event.eventType); + this.sendEventMessage(server, false, 'ClientDataReceived', 'Fail', event.clientNumber, undefined, { + ClientNumber: event.clientNumber + }); + messageQueued = true; + } + } + + if (event.eventType === 'SetClientDataRequested') { + let client = server.getClientByNumber(event.clientNumber); + let clientId; + + if (client != null) { + clientId = client.clientId; + } else { + clientId = parseInt(event.data['clientId']); + } + + this.logger.logDebug('ClientId={clientId}', clientId); + + if (clientId == null) { + this.logger.logWarning('Could not find client slot {clientNumber} when processing {eventType}', event.clientNumber, event.eventType); + this.sendEventMessage(server, false, 'SetClientDataCompleted', 'Meta', { + ClientNumber: event.clientNumber + }, undefined, { + status: 'Fail' + }); + messageQueued = true; + } else { + if (event.subType === 'Meta') { + try { + if (event.data['value'] != null && event.data['key'] != null) { + this.logger.logDebug('Key={key}, Value={value}, Direction={direction} {token}', event.data['key'], event.data['value'], event.data['direction'], token); + if (event.data['direction'] != null) { + const parsedValue = parseInt(event.data['value']); + const key = event.data['key'].toString(); + if (!isNaN(parsedValue)) { + event.data['direction'] = 'up' ? + (await metaService.incrementPersistentMeta(key, parsedValue, clientId, token)).result : + (await metaService.decrementPersistentMeta(key, parsedValue, clientId, token)).result; + } + } else { + const _ = (await metaService.setPersistentMeta(event.data['key'], event.data['value'], clientId, token)).result; + } + + if (event.data['key'] === 'PersistentClientGuid') { + const serverEvents = importNamespace('SharedLibraryCore.Events.Management'); + const persistentIdEvent = new serverEvents.ClientPersistentIdReceiveEvent(client, event.data['value']); + this.eventManager.queueEvent(persistentIdEvent); + } + } + this.sendEventMessage(server, false, 'SetClientDataCompleted', 'Meta', { + ClientNumber: event.clientNumber + }, undefined, { + status: 'Complete' + }); + messageQueued = true; + } catch (error) { + this.sendEventMessage(server, false, 'SetClientDataCompleted', 'Meta', { + ClientNumber: event.clientNumber + }, undefined, { + status: 'Fail' + }); + this.logger.logError('Could not persist client meta {Key}={Value} {error} for {Client}', event.data['key'], event.data['value'], error.toString(), clientId); + messageQueued = true; + } + } + } + } + + tokenSource.dispose(); + return messageQueued; + }, + + sendEventMessage: function (server, responseExpected, event, subtype, origin, target, data) { + let targetClientNumber = -1; + if (target != null) { + targetClientNumber = target.ClientNumber; + } + + const output = `${responseExpected ? '1' : '0'};${event};${subtype};${origin.ClientNumber};${targetClientNumber};${buildDataString(data)}`; + this.logger.logDebug('Queuing output for server {output}', output); + + servers[server.id].commandQueue.push(output); + }, + + requestGetDvar: function (dvarName, server) { + const serverState = servers[server.id]; + const serverEvents = importNamespace('SharedLibraryCore.Events.Server'); + const requestEvent = new serverEvents.ServerValueRequestEvent(dvarName, server); + requestEvent.delayMs = pollingRate; + requestEvent.timeoutMs = 2000; + requestEvent.source = this.name; + + if (server.matchEndTime !== null) { + const extraDelay = 15000; + const end = new Date(server.matchEndTime.toString()); + const diff = new Date().getTime() - end.getTime(); + + if (diff < extraDelay) { + requestEvent.delayMs = (extraDelay - diff) + pollingRate; + this.logger.logDebug('Increasing delay time to {Delay}ms due to recent map change', requestEvent.delayMs); + } + } + + this.logger.logDebug('requesting {dvar}', dvarName); + + serverState.outQueue.push(requestEvent); + + if (serverState.outQueue.length <= 1) { + this.eventManager.queueEvent(requestEvent); + } else { + this.logger.logError('[requestGetDvar] Queue is full!'); + } + }, + + requestSetDvar: function (dvarName, dvarValue, server) { + const serverState = servers[server.id]; + + const serverEvents = importNamespace('SharedLibraryCore.Events.Server'); + const requestEvent = new serverEvents.ServerValueSetRequestEvent(dvarName, dvarValue, server); + requestEvent.delayMs = pollingRate; + requestEvent.timeoutMs = 2000; + requestEvent.source = this.name; + + if (server.matchEndTime !== null) { + const extraDelay = 15000; + const end = new Date(server.matchEndTime.toString()); + const diff = new Date().getTime() - end.getTime(); + + if (diff < extraDelay) { + requestEvent.delayMs = (extraDelay - diff) + pollingRate; + this.logger.logDebug('Increasing delay time to {Delay}ms due to recent map change', requestEvent.delayMs); + } + } + + serverState.outQueue.push(requestEvent); + + this.logger.logDebug('outQueue size = {length}', serverState.outQueue.length); + + // if this is the only item in the out-queue we can send it immediately + if (serverState.outQueue.length === 1) { + this.eventManager.queueEvent(requestEvent); + } else { + this.logger.logError('[requestSetDvar] Queue is full!'); + } + }, + + onServerMonitoringStart: function (monitorStartEvent) { + this.initializeServer(monitorStartEvent.server); } }; -let commands = [{ - name: 'giveweapon', - description: 'gives specified weapon', - alias: 'gw', - permission: 'SeniorAdmin', - targetRequired: true, - arguments: [{ - name: 'player', - required: true - }, - { - name: 'weapon name', - required: true - }], - supportedGames: ['IW4', 'IW5', 'T5'], - execute: (gameEvent) => { - if (!validateEnabled(gameEvent.Owner, gameEvent.Origin)) { - return; - } - sendScriptCommand(gameEvent.Owner, 'GiveWeapon', gameEvent.Origin, gameEvent.Target, {weaponName: gameEvent.Data}); - } +const commands = [{ + name: 'giveweapon', + description: 'gives specified weapon', + alias: 'gw', + permission: 'SeniorAdmin', + targetRequired: true, + arguments: [{ + name: 'player', + required: true }, + { + name: 'weapon name', + required: true + } + ], + supportedGames: ['IW4', 'IW5', 'T5'], + execute: (gameEvent) => { + if (!validateEnabled(gameEvent.owner, gameEvent.origin)) { + return; + } + sendScriptCommand(gameEvent.owner, 'GiveWeapon', gameEvent.origin, gameEvent.target, { + weaponName: gameEvent.data + }); + } +}, { name: 'takeweapons', description: 'take all weapons from specified player', @@ -105,10 +407,10 @@ let commands = [{ }], supportedGames: ['IW4', 'IW5', 'T5'], execute: (gameEvent) => { - if (!validateEnabled(gameEvent.Owner, gameEvent.Origin)) { + if (!validateEnabled(gameEvent.owner, gameEvent.origin)) { return; } - sendScriptCommand(gameEvent.Owner, 'TakeWeapons', gameEvent.Origin, gameEvent.Target, undefined); + sendScriptCommand(gameEvent.owner, 'TakeWeapons', gameEvent.origin, gameEvent.target, undefined); } }, { @@ -123,10 +425,10 @@ let commands = [{ }], supportedGames: ['IW4', 'IW5', 'T5'], execute: (gameEvent) => { - if (!validateEnabled(gameEvent.Owner, gameEvent.Origin)) { + if (!validateEnabled(gameEvent.owner, gameEvent.origin)) { return; } - sendScriptCommand(gameEvent.Owner, 'SwitchTeams', gameEvent.Origin, gameEvent.Target, undefined); + sendScriptCommand(gameEvent.owner, 'SwitchTeams', gameEvent.origin, gameEvent.target, undefined); } }, { @@ -141,10 +443,10 @@ let commands = [{ }], supportedGames: ['IW4', 'IW5', 'T5'], execute: (gameEvent) => { - if (!validateEnabled(gameEvent.Owner, gameEvent.Origin)) { + if (!validateEnabled(gameEvent.owner, gameEvent.origin)) { return; } - sendScriptCommand(gameEvent.Owner, 'LockControls', gameEvent.Origin, gameEvent.Target, undefined); + sendScriptCommand(gameEvent.owner, 'LockControls', gameEvent.origin, gameEvent.target, undefined); } }, { @@ -156,10 +458,10 @@ let commands = [{ arguments: [], supportedGames: ['IW4', 'IW5'], execute: (gameEvent) => { - if (!validateEnabled(gameEvent.Owner, gameEvent.Origin)) { + if (!validateEnabled(gameEvent.owner, gameEvent.origin)) { return; } - sendScriptCommand(gameEvent.Owner, 'NoClip', gameEvent.Origin, gameEvent.Origin, undefined); + sendScriptCommand(gameEvent.owner, 'NoClip', gameEvent.origin, gameEvent.origin, undefined); } }, { @@ -171,10 +473,10 @@ let commands = [{ arguments: [], supportedGames: ['IW4', 'IW5', 'T5'], execute: (gameEvent) => { - if (!validateEnabled(gameEvent.Owner, gameEvent.Origin)) { + if (!validateEnabled(gameEvent.owner, gameEvent.origin)) { return; } - sendScriptCommand(gameEvent.Owner, 'Hide', gameEvent.Origin, gameEvent.Origin, undefined); + sendScriptCommand(gameEvent.owner, 'Hide', gameEvent.origin, gameEvent.origin, undefined); } }, { @@ -190,13 +492,14 @@ let commands = [{ { name: 'message', required: true - }], + } + ], supportedGames: ['IW4', 'IW5', 'T5'], execute: (gameEvent) => { - if (!validateEnabled(gameEvent.Owner, gameEvent.Origin)) { + if (!validateEnabled(gameEvent.owner, gameEvent.origin)) { return; } - sendScriptCommand(gameEvent.Owner, 'Alert', gameEvent.Origin, gameEvent.Target, { + sendScriptCommand(gameEvent.Owner, 'Alert', gameEvent.origin, gameEvent.target, { alertType: 'Alert', message: gameEvent.Data }); @@ -214,10 +517,10 @@ let commands = [{ }], supportedGames: ['IW4', 'IW5', 'T5'], execute: (gameEvent) => { - if (!validateEnabled(gameEvent.Owner, gameEvent.Origin)) { + if (!validateEnabled(gameEvent.owner, gameEvent.origin)) { return; } - sendScriptCommand(gameEvent.Owner, 'Goto', gameEvent.Origin, gameEvent.Target, undefined); + sendScriptCommand(gameEvent.owner, 'Goto', gameEvent.origin, gameEvent.target, undefined); } }, { @@ -232,10 +535,10 @@ let commands = [{ }], supportedGames: ['IW4', 'IW5', 'T5'], execute: (gameEvent) => { - if (!validateEnabled(gameEvent.Owner, gameEvent.Origin)) { + if (!validateEnabled(gameEvent.owner, gameEvent.origin)) { return; } - sendScriptCommand(gameEvent.Owner, 'PlayerToMe', gameEvent.Origin, gameEvent.Target, undefined); + sendScriptCommand(gameEvent.owner, 'PlayerToMe', gameEvent.origin, gameEvent.target, undefined); } }, { @@ -255,15 +558,16 @@ let commands = [{ { name: 'z', required: true - }], + } + ], supportedGames: ['IW4', 'IW5', 'T5'], execute: (gameEvent) => { - if (!validateEnabled(gameEvent.Owner, gameEvent.Origin)) { + if (!validateEnabled(gameEvent.owner, gameEvent.origin)) { return; } const args = String(gameEvent.Data).split(' '); - sendScriptCommand(gameEvent.Owner, 'Goto', gameEvent.Origin, gameEvent.Target, { + sendScriptCommand(gameEvent.owner, 'Goto', gameEvent.origin, gameEvent.target, { x: args[0], y: args[1], z: args[2] @@ -282,10 +586,10 @@ let commands = [{ }], supportedGames: ['IW4', 'IW5', 'T5'], execute: (gameEvent) => { - if (!validateEnabled(gameEvent.Owner, gameEvent.Origin)) { + if (!validateEnabled(gameEvent.owner, gameEvent.origin)) { return; } - sendScriptCommand(gameEvent.Owner, 'Kill', gameEvent.Origin, gameEvent.Target, undefined); + sendScriptCommand(gameEvent.owner, 'Kill', gameEvent.origin, gameEvent.target, undefined); } }, { @@ -300,244 +604,30 @@ let commands = [{ }], supportedGames: ['IW4', 'IW5', 'T5'], execute: (gameEvent) => { - if (!validateEnabled(gameEvent.Owner, gameEvent.Origin)) { + if (!validateEnabled(gameEvent.owner, gameEvent.origin)) { return; } - sendScriptCommand(gameEvent.Owner, 'SetSpectator', gameEvent.Origin, gameEvent.Target, undefined); + sendScriptCommand(gameEvent.owner, 'SetSpectator', gameEvent.origin, gameEvent.target, undefined); } - }]; + } +]; const sendScriptCommand = (server, command, origin, target, data) => { - const state = servers[server.EndPoint]; - if (state === undefined || !state.enabled) { + const serverState = servers[server.id]; + if (serverState === undefined || !serverState.enabled) { return; } - sendEvent(server, false, 'ExecuteCommandRequested', command, origin, target, data); -} - -const sendEvent = (server, responseExpected, event, subtype, origin, target, data) => { - const logger = _serviceResolver.ResolveService('ILogger'); - const state = servers[server.EndPoint]; - - if (state.queuedMessages.length >= maxQueuedMessages) { - logger.WriteWarning('Too many queued messages so we are skipping'); - return; - } - - let targetClientNumber = -1; - if (target != null) { - targetClientNumber = target.ClientNumber; - } - - const output = `${responseExpected ? '1' : '0'};${event};${subtype};${origin.ClientNumber};${targetClientNumber};${buildDataString(data)}`; - logger.WriteDebug(`Queuing output for server ${output}`); - - state.queuedMessages.push(output); + plugin.sendEventMessage(server, false, 'ExecuteCommandRequested', command, origin, target, data); }; -const initialize = (server) => { - const logger = _serviceResolver.ResolveService('ILogger'); - - servers[server.EndPoint] = { - enabled: false - } - - let enabled = false; - try { - enabled = server.GetServerDvar('sv_iw4madmin_integration_enabled', enableCheckTimeout) === '1'; - } catch (error) { - logger.WriteError(`Could not get integration status of ${server.EndPoint} - ${error}`); - } - - logger.WriteInfo(`GSC Integration enabled = ${enabled}`); - - if (!enabled) { - return false; - } - - logger.WriteDebug(`Setting up bus timer for ${server.EndPoint}`); - - let timer = _serviceResolver.ResolveService('IScriptPluginTimerHelper'); - timer.OnTick(() => pollForEvents(server), `GameEventPoller ${server.ToString()}`); - // necessary to prevent multi-threaded access to the JS context - timer.SetDependency(_lock); - - servers[server.EndPoint].timer = timer; - servers[server.EndPoint].enabled = true; - servers[server.EndPoint].waitingOnInput = false; - servers[server.EndPoint].waitingOnOutput = false; - servers[server.EndPoint].queuedMessages = []; - - setDvar(server, inDvar, '', onSetDvar); - setDvar(server, outDvar, '', onSetDvar); - - return true; -} - const getClientStats = (client, server) => { - const contextFactory = _serviceResolver.ResolveService('IDatabaseContextFactory'); - const context = contextFactory.CreateContext(false); - const stats = context.ClientStatistics.GetClientsStatData([client.ClientId], server.GetId()); // .Find(client.ClientId, serverId); - context.Dispose(); - - return stats.length > 0 ? stats[0] : undefined; -} + const contextFactory = plugin.serviceResolver.ResolveService('IDatabaseContextFactory'); + const context = contextFactory.createContext(false); + const stats = context.clientStatistics.getClientsStatData([client.ClientId], server.legacyDatabaseId); + context.dispose(); -function onReceivedDvar(server, dvarName, dvarValue, success) { - const logger = _serviceResolver.ResolveService('ILogger'); - logger.WriteDebug(`Received ${dvarName}=${dvarValue} success=${success}`); - - let input = dvarValue; - const state = servers[server.EndPoint]; - - if (state.waitingOnOutput && dvarName === outDvar && isEmpty(dvarValue)) { - logger.WriteDebug('Setting out bus to read to send'); - // reset our flag letting use the out bus is open - state.waitingOnOutput = !success; - } - - if (state.waitingOnInput && dvarName === inDvar) { - logger.WriteDebug('Setting in bus to ready to receive'); - // we've received the data so now we can mark it as ready for more - state.waitingOnInput = false; - } - - if (isEmpty(input)) { - input = ''; - } - - if (input.length > 0) { - const event = parseEvent(input) - - logger.WriteDebug(`Processing input... ${event.eventType} ${event.subType} ${event.data.toString()} ${event.clientNumber}`); - - const metaService = _serviceResolver.ResolveService('IMetaServiceV2'); - const threading = importNamespace('System.Threading'); - const token = new threading.CancellationTokenSource().Token; - - // todo: refactor to mapping if possible - if (event.eventType === 'ClientDataRequested') { - const client = server.GetClientByNumber(event.clientNumber); - - if (client != null) { - logger.WriteDebug(`Found client ${client.Name}`); - - let data = []; - - const metaService = _serviceResolver.ResolveService('IMetaServiceV2'); - - if (event.subType === 'Meta') { - const meta = metaService.GetPersistentMeta(event.data, client.ClientId, token).GetAwaiter().GetResult(); - data[event.data] = meta === null ? '' : meta.Value; - logger.WriteDebug(`event data is ${event.data}`); - } else { - const clientStats = getClientStats(client, server); - const tagMeta = metaService.GetPersistentMetaByLookup('ClientTagV2', 'ClientTagNameV2', client.ClientId, token).GetAwaiter().GetResult(); - data = { - level: client.Level, - clientId: client.ClientId, - lastConnection: client.LastConnection, - tag: tagMeta?.Value ?? '', - performance: clientStats?.Performance ?? 200.0 - }; - } - - sendEvent(server, false, 'ClientDataReceived', event.subType, client, undefined, data); - } else { - logger.WriteWarning(`Could not find client slot ${event.clientNumber} when processing ${event.eventType}`); - sendEvent(server, false, 'ClientDataReceived', 'Fail', event.clientNumber, undefined, {ClientNumber: event.clientNumber}); - } - } - - if (event.eventType === 'SetClientDataRequested') { - let client = server.GetClientByNumber(event.clientNumber); - let clientId; - - if (client != null) { - clientId = client.ClientId; - } else { - clientId = parseInt(event.data.clientId); - } - - logger.WriteDebug(`ClientId=${clientId}`); - - if (clientId == null) { - logger.WriteWarning(`Could not find client slot ${event.clientNumber} when processing ${event.eventType}`); - sendEvent(server, false, 'SetClientDataCompleted', 'Meta', {ClientNumber: event.clientNumber}, undefined, {status: 'Fail'}); - } else { - if (event.subType === 'Meta') { - try { - if (event.data['value'] != null && event.data['key'] != null) { - logger.WriteDebug(`Key=${event.data['key']}, Value=${event.data['value']}, Direction=${event.data['direction']} ${token}`); - if (event.data['direction'] != null) { - event.data['direction'] = 'up' - ? metaService.IncrementPersistentMeta(event.data['key'], parseInt(event.data['value']), clientId, token).GetAwaiter().GetResult() - : metaService.DecrementPersistentMeta(event.data['key'], parseInt(event.data['value']), clientId, token).GetAwaiter().GetResult(); - } else { - metaService.SetPersistentMeta(event.data['key'], event.data['value'], clientId, token).GetAwaiter().GetResult(); - } - } - sendEvent(server, false, 'SetClientDataCompleted', 'Meta', {ClientNumber: event.clientNumber}, undefined, {status: 'Complete'}); - } catch (error) { - sendEvent(server, false, 'SetClientDataCompleted', 'Meta', {ClientNumber: event.clientNumber}, undefined, {status: 'Fail'}); - logger.WriteError('Could not persist client meta ' + error.toString()); - } - } - } - } - - setDvar(server, inDvar, '', onSetDvar); - } else if (server.ClientNum === 0) { - servers[server.EndPoint].timer.Stop(); - } -} - -function onSetDvar(server, dvarName, dvarValue, success) { - const logger = _serviceResolver.ResolveService('ILogger'); - logger.WriteDebug(`Completed set of dvar ${dvarName}=${dvarValue}, success=${success}`); - - const state = servers[server.EndPoint]; - - if (dvarName === inDvar && success && isEmpty(dvarValue)) { - logger.WriteDebug('In bus is ready for new data'); - // reset our flag letting use the in bus is ready for more data - state.waitingOnInput = false; - } -} - -const pollForEvents = server => { - const state = servers[server.EndPoint]; - - if (state === null || !state.enabled) { - return; - } - - if (server.Throttled) { - logger.WriteDebug('Server is throttled so we are not polling for game data'); - return; - } - - if (!state.waitingOnInput) { - state.waitingOnInput = true; - logger.WriteDebug('Attempting to get in dvar value'); - getDvar(server, inDvar, onReceivedDvar); - } - - if (!state.waitingOnOutput) { - if (state.queuedMessages.length === 0) { - logger.WriteDebug('No messages in queue'); - return; - } - - state.waitingOnOutput = true; - const nextMessage = state.queuedMessages.splice(0, 1); - setDvar(server, outDvar, nextMessage, onSetDvar); - } - - if (state.waitingOnOutput) { - getDvar(server, outDvar, onReceivedDvar); - } -} + return stats.length > 0 ? stats[0] : undefined; +}; const parseEvent = (input) => { if (input === undefined) { @@ -551,8 +641,8 @@ const parseEvent = (input) => { subType: eventInfo[2], clientNumber: eventInfo[3], data: eventInfo.length > 4 ? parseDataString(eventInfo[4]) : undefined - } -} + }; +}; const buildDataString = data => { if (data === undefined) { @@ -561,21 +651,23 @@ const buildDataString = data => { let formattedData = ''; - for (const prop in data) { - formattedData += `${prop}=${data[prop]}|`; + for (let [key, value] of Object.entries(data)) { + formattedData += `${key}=${value}|`; } - return formattedData.substring(0, Math.max(0, formattedData.length - 1)); -} + return formattedData.slice(0, -1); +}; const parseDataString = data => { if (data === undefined) { return ''; } - const dict = {} + const dict = {}; + const split = data.split('|'); - for (const segment of data.split('|')) { + for (let i = 0; i < split.length; i++) { + const segment = split[i]; const keyValue = segment.split('='); if (keyValue.length !== 2) { continue; @@ -584,16 +676,16 @@ const parseDataString = data => { } return Object.keys(dict).length === 0 ? data : dict; -} +}; const validateEnabled = (server, origin) => { - const enabled = servers[server.EndPoint] != null && servers[server.EndPoint].enabled; + const enabled = servers[server.id] != null && servers[server.id].enabled; if (!enabled) { - origin.Tell('Game interface is not enabled on this server'); + origin.tell('Game interface is not enabled on this server'); } return enabled; -} +}; -function isEmpty(value) { +const isEmpty = (value) => { return value == null || false || value === '' || value === 'null'; -} +}; diff --git a/Plugins/ScriptPlugins/SampleScriptPluginCommand.js b/Plugins/ScriptPlugins/SampleScriptPluginCommand.js deleted file mode 100644 index 90c5903aa..000000000 --- a/Plugins/ScriptPlugins/SampleScriptPluginCommand.js +++ /dev/null @@ -1,90 +0,0 @@ -let commands = [{ - // required - name: "pingpong", - // required - description: "pongs a ping", - // required - alias: "pp", - // required - permission: "User", - // optional (defaults to false) - targetRequired: false, - // optional - arguments: [{ - name: "times to ping", - required: true - }], - // required - execute: (gameEvent) => { - // parse the first argument (number of times) - let times = parseInt(gameEvent.Data); - - // we only want to allow ping pong up to 5 times - if (times > 5 || times <= 0) { - gameEvent.Origin.Tell("You can only ping pong between 1 and 5 times"); - return; - } - - // we want to print out a pong message for the number of times they requested - for (var i = 0; i < times; i++) { - gameEvent.Origin.Tell(`^${i}pong #${i + 1}^7`); - - // don't want to wait if it's the last pong - if (i < times - 1) { - System.Threading.Tasks.Task.Delay(1000).Wait(); - } - } - } -}]; - -let plugin = { - author: 'RaidMax', - version: 1.1, - name: 'Ping Pong Sample Command Plugin', - - onEventAsync: function (gameEvent, server) { - }, - - onLoadAsync: function (manager) { - this.logger = _serviceResolver.ResolveService("ILogger"); - this.logger.WriteDebug("sample plugin loaded"); - - const intArray = [ - 1337, - 1505, - 999 - ]; - - const stringArray = [ - "ping", - "pong", - "hello" - ]; - - this.configHandler = _configHandler; - - this.configHandler.SetValue("SampleIntegerValue", 123); - this.configHandler.SetValue("SampleStringValue", this.author); - this.configHandler.SetValue("SampleFloatValue", this.version); - this.configHandler.SetValue("SampleNumericalArray", intArray); - this.configHandler.SetValue("SampleStringArray", stringArray); - - this.logger.WriteDebug(this.configHandler.GetValue("SampleIntegerValue")); - this.logger.WriteDebug(this.configHandler.GetValue("SampleStringValue")); - this.logger.WriteDebug(this.configHandler.GetValue("SampleFloatValue")); - - this.configHandler.GetValue("SampleNumericalArray").forEach((element) => { - this.logger.WriteDebug(element); - }); - - this.configHandler.GetValue("SampleStringArray").forEach((element) => { - this.logger.WriteDebug(element); - }); - }, - - onUnloadAsync: function () { - }, - - onTickAsync: function (server) { - } -}; \ No newline at end of file diff --git a/Plugins/ScriptPlugins/SharedGUIDKick.js b/Plugins/ScriptPlugins/SharedGUIDKick.js deleted file mode 100644 index 77f4827f5..000000000 --- a/Plugins/ScriptPlugins/SharedGUIDKick.js +++ /dev/null @@ -1,39 +0,0 @@ -var plugin = { - author: 'RaidMax', - version: 1.1, - name: 'Shared GUID Kicker Plugin', - - onEventAsync: function (gameEvent, server) { - // make sure we only check for IW4(x) - if (server.GameName !== 2) { - return false; - } - - // connect or join event - if (gameEvent.Type === 3) { - // this GUID seems to have been packed in a IW4 torrent and results in an unreasonable amount of people using the same GUID - if (gameEvent.Origin.NetworkId === -805366929435212061 || - gameEvent.Origin.NetworkId === 3150799945255696069 || - gameEvent.Origin.NetworkId === 5859032128210324569 || - gameEvent.Origin.NetworkId === 2908745942105435771 || - gameEvent.Origin.NetworkId === -6492697076432899192 || - gameEvent.Origin.NetworkId === 1145760003260769995 || - gameEvent.Origin.NetworkId === -7102887284306116957 || - gameEvent.Origin.NetworkId === 3474936520447289592 || - gameEvent.Origin.NetworkId === -1168897558496584395 || - gameEvent.Origin.NetworkId === 8348020621355817691 || - gameEvent.Origin.NetworkId === 3259219574061214058 || - gameEvent.Origin.NetworkId === 3304388024725980231) { - gameEvent.Origin.Kick('Your GUID is generic. Delete players/guids.dat and rejoin', _IW4MAdminClient); - } - } - }, - onLoadAsync: function (manager) { - }, - - onUnloadAsync: function () { - }, - - onTickAsync: function (server) { - } -}; \ No newline at end of file diff --git a/Plugins/ScriptPlugins/SubnetBan.js b/Plugins/ScriptPlugins/SubnetBan.js index 00f98eaa6..8c71a6bd2 100644 --- a/Plugins/ScriptPlugins/SubnetBan.js +++ b/Plugins/ScriptPlugins/SubnetBan.js @@ -3,151 +3,92 @@ const validCIDR = input => cidrRegex.test(input); const subnetBanlistKey = 'Webfront::Nav::Admin::SubnetBanlist'; let subnetList = []; -const commands = [{ - name: "bansubnet", - description: "bans an IPv4 subnet", - alias: "bs", - permission: "SeniorAdmin", - targetRequired: false, - arguments: [{ - name: "subnet in IPv4 CIDR notation", - required: true - }], +const init = (registerNotify, serviceResolver, config) => { + registerNotify('IManagementEventSubscriptions.ClientStateAuthorized', (authorizedEvent, _) => plugin.onClientAuthorized(authorizedEvent)); - execute: (gameEvent) => { - const input = String(gameEvent.Data).trim(); + plugin.onLoad(serviceResolver, config); + return plugin; +}; - if (!validCIDR(input)) { - gameEvent.Origin.Tell('Invalid CIDR input'); - return; - } +const plugin = { + author: 'RaidMax', + version: '2.0', + name: 'Subnet Banlist Plugin', + manager: null, + logger: null, + config: null, + serviceResolver: null, + banMessage: '', - subnetList.push(input); - _configHandler.SetValue('SubnetBanList', subnetList); - - gameEvent.Origin.Tell(`Added ${input} to subnet banlist`); - } -}, - { - name: 'unbansubnet', - description: 'unbans an IPv4 subnet', - alias: 'ubs', + commands: [{ + name: 'bansubnet', + description: 'bans an IPv4 subnet', + alias: 'bs', permission: 'SeniorAdmin', targetRequired: false, arguments: [{ name: 'subnet in IPv4 CIDR notation', required: true }], + execute: (gameEvent) => { - const input = String(gameEvent.Data).trim(); + const input = String(gameEvent.data).trim(); if (!validCIDR(input)) { - gameEvent.Origin.Tell('Invalid CIDR input'); + gameEvent.origin.tell('Invalid CIDR input'); return; } - if (!subnetList.includes(input)) { - gameEvent.Origin.Tell('Subnet is not banned'); - return; - } + subnetList.push(input); + plugin.config.setValue('SubnetBanList', subnetList); - subnetList = subnetList.filter(item => item !== input); - _configHandler.SetValue('SubnetBanList', subnetList); - - gameEvent.Origin.Tell(`Removed ${input} from subnet banlist`); - } - }]; - -convertIPtoLong = ip => { - let components = String(ip).match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); - if (components) { - let ipLong = 0; - let power = 1; - for (let i = 4; i >= 1; i -= 1) { - ipLong += power * parseInt(components[i]); - power *= 256; - } - return ipLong; - } else { - return -1; - } -}; - -isInSubnet = (ip, subnet) => { - const mask = subnet.match(/^(.*?)\/(\d{1,2})$/); - - if (!mask) { - return false; - } - - const baseIP = convertIPtoLong(mask[1]); - const longIP = convertIPtoLong(ip); - - if (mask && baseIP >= 0) { - const freedom = Math.pow(2, 32 - parseInt(mask[2])); - return (longIP > baseIP) && (longIP < baseIP + freedom - 1); - } else return false; -}; - -isSubnetBanned = (ip, list) => { - const matchingSubnets = list.filter(subnet => isInSubnet(ip, subnet)); - return matchingSubnets.length !== 0; -} - -const plugin = { - author: 'RaidMax', - version: 1.1, - name: 'Subnet Banlist Plugin', - manager: null, - logger: null, - banMessage: '', - - onEventAsync: (gameEvent, server) => { - if (gameEvent.TypeName === 'Join') { - if (!isSubnetBanned(gameEvent.Origin.IPAddressString, subnetList, this.logger)) { - return; - } - - this.logger.WriteInfo(`Kicking ${gameEvent.Origin} because they are subnet banned.`); - gameEvent.Origin.Kick(this.banMessage, _IW4MAdminClient); + gameEvent.origin.tell(`Added ${input} to subnet banlist`); } }, - onLoadAsync: manager => { - this.manager = manager; - this.logger = manager.GetLogger(0); - this.configHandler = _configHandler; - subnetList = []; - this.interactionRegistration = _serviceResolver.ResolveService('IInteractionRegistration'); + { + name: 'unbansubnet', + description: 'unbans an IPv4 subnet', + alias: 'ubs', + permission: 'SeniorAdmin', + targetRequired: false, + arguments: [{ + name: 'subnet in IPv4 CIDR notation', + required: true + }], + execute: (gameEvent) => { + const input = String(gameEvent.data).trim(); - const list = this.configHandler.GetValue('SubnetBanList'); - if (list !== undefined) { - list.forEach(element => { - const ban = String(element); - subnetList.push(ban) - }); - this.logger.WriteInfo(`Loaded ${list.length} banned subnets`); - } else { - this.configHandler.SetValue('SubnetBanList', []); + if (!validCIDR(input)) { + gameEvent.origin.tell('Invalid CIDR input'); + return; + } + + if (!subnetList.includes(input)) { + gameEvent.origin.tell('Subnet is not banned'); + return; + } + + subnetList = subnetList.filter(item => item !== input); + plugin.config.setValue('SubnetBanList', subnetList); + + gameEvent.origin.tell(`Removed ${input} from subnet banlist`); + } } + ], - this.banMessage = this.configHandler.GetValue('BanMessage'); - - if (this.banMessage === undefined) { - this.banMessage = 'You are not allowed to join this server.'; - this.configHandler.SetValue('BanMessage', this.banMessage); - } - - this.interactionRegistration.RegisterScriptInteraction(subnetBanlistKey, plugin.name, (targetId, game, token) => { + interactions: [{ + name: subnetBanlistKey, + action: function (_, __, ___) { const helpers = importNamespace('SharedLibraryCore.Helpers'); const interactionData = new helpers.InteractionData(); - interactionData.Name = 'Subnet Banlist'; // navigation link name - interactionData.Description = `List of banned subnets (${subnetList.length} Total)`; // alt and title - interactionData.DisplayMeta = 'oi-circle-x'; // nav icon - interactionData.InteractionId = subnetBanlistKey; - interactionData.MinimumPermission = 3; // moderator - interactionData.InteractionType = 2; // 1 is RawContent for apis etc..., 2 is - interactionData.Source = plugin.name; + interactionData.name = 'Subnet Banlist'; // navigation link name + interactionData.description = `List of banned subnets (${subnetList.length} Total)`; // alt and title + interactionData.displayMeta = 'oi-circle-x'; // nav icon + interactionData.interactionId = subnetBanlistKey; + interactionData.minimumPermission = 3; + interactionData.interactionType = 2; + interactionData.source = plugin.name; interactionData.ScriptAction = (sourceId, targetId, game, meta, token) => { let table = ''; @@ -160,7 +101,7 @@ const plugin = { }; subnetList.forEach(subnet => { - unbanSubnetInteraction.Data += ' ' + subnet + unbanSubnetInteraction.Data += ' ' + subnet; table += `

${subnet}

@@ -180,16 +121,84 @@ const plugin = { table += '
'; return table; - } + }; return interactionData; - }); + } + }], + + onLoad: function (serviceResolver, config) { + this.serviceResolver = serviceResolver; + this.config = config; + this.logger = serviceResolver.resolveService('ILogger', ['ScriptPluginV2']); + subnetList = []; + + const list = this.config.getValue('SubnetBanList'); + if (list !== undefined) { + list.forEach(element => { + const ban = String(element); + subnetList.push(ban); + }); + this.logger.logInformation('Loaded {Count} banned subnets', list.length); + } else { + this.config.setValue('SubnetBanList', []); + } + + this.banMessage = this.config.getValue('BanMessage'); + + if (this.banMessage === undefined) { + this.banMessage = 'You are not allowed to join this server.'; + this.config.setValue('BanMessage', this.banMessage); + } + + const interactionRegistration = serviceResolver.resolveService('IInteractionRegistration'); + interactionRegistration.unregisterInteraction(subnetBanlistKey); + + this.logger.logInformation('Subnet Ban loaded'); }, - onUnloadAsync: () => { - this.interactionRegistration.UnregisterInteraction(subnetBanlistKey); - }, + onClientAuthorized: (clientEvent) => { + if (!isSubnetBanned(clientEvent.client.ipAddressString, subnetList)) { + return; + } - onTickAsync: server => { + this.logger.logInformation(`Kicking {Client} because they are subnet banned.`, clientEvent.client); + clientEvent.client.kick(this.banMessage, clientEvent.client.currentServer.asConsoleClient()); } }; + +const convertIPtoLong = ip => { + let components = String(ip).match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (components) { + let ipLong = 0; + let power = 1; + for (let i = 4; i >= 1; i -= 1) { + ipLong += power * parseInt(components[i]); + power *= 256; + } + return ipLong; + } else { + return -1; + } +}; + +const isInSubnet = (ip, subnet) => { + const mask = subnet.match(/^(.*?)\/(\d{1,2})$/); + + if (!mask) { + return false; + } + + const baseIP = convertIPtoLong(mask[1]); + const longIP = convertIPtoLong(ip); + + if (mask && baseIP >= 0) { + const freedom = Math.pow(2, 32 - parseInt(mask[2])); + return (longIP > baseIP) && (longIP < baseIP + freedom - 1); + } else return false; +}; + +const isSubnetBanned = (ip, list) => { + const matchingSubnets = list.filter(subnet => isInSubnet(ip, subnet)); + return matchingSubnets.length !== 0; +}; diff --git a/Plugins/ScriptPlugins/VPNDetection.js b/Plugins/ScriptPlugins/VPNDetection.js index ab84bf40a..2e1cc861e 100644 --- a/Plugins/ScriptPlugins/VPNDetection.js +++ b/Plugins/ScriptPlugins/VPNDetection.js @@ -2,22 +2,23 @@ let vpnExceptionIds = []; const vpnAllowListKey = 'Webfront::Nav::Admin::VPNAllowList'; const vpnWhitelistKey = 'Webfront::Profile::VPNWhitelist'; -const init = (registerNotify, serviceResolver, config) => { - registerNotify('IManagementEventSubscriptions.ClientStateAuthorized', (authorizedEvent, _) => plugin.onClientAuthorized(authorizedEvent)); +const init = (registerNotify, serviceResolver, config, pluginHelper) => { + registerNotify('IManagementEventSubscriptions.ClientStateAuthorized', (authorizedEvent, token) => plugin.onClientAuthorized(authorizedEvent, token)); - plugin.onLoad(serviceResolver, config); + plugin.onLoad(serviceResolver, config, pluginHelper); return plugin; }; const plugin = { author: 'RaidMax', - version: '1.6', + version: '2.0', name: 'VPN Detection Plugin', manager: null, config: null, logger: null, serviceResolver: null, translations: null, + pluginHelper: null, commands: [{ name: 'whitelistvpn', @@ -58,7 +59,7 @@ const plugin = { interactions: [{ // registers the profile action name: vpnWhitelistKey, - action: function(targetId, game, token) { + action: function (targetId, game, token) { const helpers = importNamespace('SharedLibraryCore.Helpers'); const interactionData = new helpers.InteractionData(); @@ -91,7 +92,7 @@ const plugin = { }, { name: vpnAllowListKey, - action: function(targetId, game, token) { + action: function (targetId, game, token) { const helpers = importNamespace('SharedLibraryCore.Helpers'); const interactionData = new helpers.InteractionData(); @@ -146,13 +147,17 @@ const plugin = { } ], - onClientAuthorized: function(authorizeEvent) { - this.checkForVpn(authorizeEvent.client); + onClientAuthorized: async function (authorizeEvent, token) { + if (authorizeEvent.client.isBot) { + return; + } + await this.checkForVpn(authorizeEvent.client, token); }, - onLoad: function(serviceResolver, config) { + onLoad: function (serviceResolver, config, pluginHelper) { this.serviceResolver = serviceResolver; this.config = config; + this.pluginHelper = pluginHelper; this.manager = this.serviceResolver.resolveService('IManager'); this.logger = this.serviceResolver.resolveService('ILogger', ['ScriptPluginV2']); this.translations = this.serviceResolver.resolveService('ITranslationLookup'); @@ -166,10 +171,10 @@ const plugin = { this.interactionRegistration.unregisterInteraction(vpnAllowListKey); }, - checkForVpn: function(origin) { + checkForVpn: async function (origin, token) { let exempt = false; // prevent players that are exempt from being kicked - vpnExceptionIds.forEach(function(id) { + vpnExceptionIds.forEach(function (id) { if (parseInt(id) === parseInt(origin.clientId)) { exempt = true; return false; @@ -181,25 +186,40 @@ const plugin = { return; } - let usingVPN = false; - - try { - const cl = new System.Net.Http.HttpClient(); - const re = cl.getAsync(`https://api.xdefcon.com/proxy/check/?ip=${origin.IPAddressString}`).result; - const userAgent = `IW4MAdmin-${this.manager.getApplicationSettings().configuration().id}`; - cl.defaultRequestHeaders.add('User-Agent', userAgent); - const co = re.content; - const parsedJSON = JSON.parse(co.readAsStringAsync().result); - co.dispose(); - re.dispose(); - cl.dispose(); - usingVPN = parsedJSON.success && parsedJSON.proxy; - } catch (ex) { - this.logger.logWarning('There was a problem checking client IP for VPN {message}', ex.message); + if (origin.IPAddressString === null) { + this.logger.logDebug('{Client} does not have an IP Address yet, so we are no checking their VPN status', origin); } + const userAgent = `IW4MAdmin-${this.manager.getApplicationSettings().configuration().id}`; + const headers = { + 'User-Agent': userAgent + }; + + try { + this.pluginHelper.getUrl(`https://api.xdefcon.com/proxy/check/?ip=${origin.IPAddressString}`, headers, + (response) => this.onVpnResponse(response, origin)); + + } catch (ex) { + this.logger.logWarning('There was a problem checking client IP ({IP}) for VPN - {message}', + origin.IPAddressString, ex.message); + } + }, + + onVpnResponse: function (response, origin) { + let parsedJSON = null; + + try { + parsedJSON = JSON.parse(response); + } catch { + this.logger.logWarning('There was a problem checking client IP ({IP}) for VPN - {message}', + origin.IPAddressString, response); + return; + } + + const usingVPN = parsedJSON.success && parsedJSON.proxy; + if (usingVPN) { - this.logger.logInformation('{origin} is using a VPN ({ip})', origin.toString(), origin.ipAddressString); + this.logger.logInformation('{origin} is using a VPN ({ip})', origin.toString(), origin.IPAddressString); const contactUrl = this.manager.getApplicationSettings().configuration().contactUri; let additionalInfo = ''; if (contactUrl) { @@ -207,11 +227,11 @@ const plugin = { } origin.kick(this.translations['SERVER_KICK_VPNS_NOTALLOWED'] + ' ' + additionalInfo, origin.currentServer.asConsoleClient()); } else { - this.logger.logDebug('{client} is not using a VPN', origin); + this.logger.logDebug('{Client} is not using a VPN', origin); } }, - getClientsData: function(clientIds) { + getClientsData: function (clientIds) { const contextFactory = this.serviceResolver.resolveService('IDatabaseContextFactory'); const context = contextFactory.createContext(false); const clientSet = context.clients; diff --git a/SharedLibraryCore/Interfaces/IPlugin.cs b/SharedLibraryCore/Interfaces/IPlugin.cs index a506d972c..9d80bfc06 100644 --- a/SharedLibraryCore/Interfaces/IPlugin.cs +++ b/SharedLibraryCore/Interfaces/IPlugin.cs @@ -13,4 +13,4 @@ namespace SharedLibraryCore.Interfaces Task OnEventAsync(GameEvent gameEvent, Server server); Task OnTickAsync(Server S); } -} \ No newline at end of file +} diff --git a/SharedLibraryCore/Interfaces/IPluginImporter.cs b/SharedLibraryCore/Interfaces/IPluginImporter.cs index 3f0fb5cb6..683b46d36 100644 --- a/SharedLibraryCore/Interfaces/IPluginImporter.cs +++ b/SharedLibraryCore/Interfaces/IPluginImporter.cs @@ -18,6 +18,6 @@ namespace SharedLibraryCore.Interfaces /// discovers the script plugins /// /// initialized script plugin collection - IEnumerable DiscoverScriptPlugins(); + IEnumerable<(Type, string)> DiscoverScriptPlugins(); } } diff --git a/SharedLibraryCore/Interfaces/IPluginV2.cs b/SharedLibraryCore/Interfaces/IPluginV2.cs new file mode 100644 index 000000000..b86f97901 --- /dev/null +++ b/SharedLibraryCore/Interfaces/IPluginV2.cs @@ -0,0 +1,11 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace SharedLibraryCore.Interfaces; + + +public interface IPluginV2 : IModularAssembly +{ + static void RegisterDependencies(IServiceCollection serviceProvider) + { + } +} diff --git a/SharedLibraryCore/Interfaces/IScriptCommandFactory.cs b/SharedLibraryCore/Interfaces/IScriptCommandFactory.cs index 704d5572c..e8b4c9d6b 100644 --- a/SharedLibraryCore/Interfaces/IScriptCommandFactory.cs +++ b/SharedLibraryCore/Interfaces/IScriptCommandFactory.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using Data.Models; +using SharedLibraryCore.Commands; namespace SharedLibraryCore.Interfaces { @@ -18,11 +20,11 @@ namespace SharedLibraryCore.Interfaces /// minimum required permission /// target required or not /// command arguments (name, is required) - /// action to peform when commmand is executed + /// action to perform when command is executed /// /// IManagerCommand CreateScriptCommand(string name, string alias, string description, string permission, - bool isTargetRequired, IEnumerable<(string, bool)> args, Func executeAction, - Server.Game[] supportedGames); + bool isTargetRequired, IEnumerable args, Func executeAction, + IEnumerable supportedGames); } } diff --git a/SharedLibraryCore/Interfaces/IScriptPluginFactory.cs b/SharedLibraryCore/Interfaces/IScriptPluginFactory.cs new file mode 100644 index 000000000..b10fddf52 --- /dev/null +++ b/SharedLibraryCore/Interfaces/IScriptPluginFactory.cs @@ -0,0 +1,8 @@ +using System; + +namespace SharedLibraryCore.Interfaces; + +public interface IScriptPluginFactory +{ + object CreateScriptPlugin(Type type, string fileName); +} diff --git a/WebfrontCore/Controllers/InteractionController.cs b/WebfrontCore/Controllers/InteractionController.cs index 5ec73df27..8610219a2 100644 --- a/WebfrontCore/Controllers/InteractionController.cs +++ b/WebfrontCore/Controllers/InteractionController.cs @@ -27,11 +27,18 @@ public class InteractionController : BaseController } ViewBag.Title = interactionData.Description; + var meta = HttpContext.Request.Query.ToDictionary(key => key.Key, value => value.Value.ToString()); + var result = await _interactionRegistration.ProcessInteraction(interactionName, Client.ClientId, meta: meta, token: token); - var result = await _interactionRegistration.ProcessInteraction(interactionName, Client.ClientId, token: token); - - return interactionData.InteractionType == InteractionType.TemplateContent - ? View("Render", result ?? "") - : Ok(result); + if (interactionData.InteractionType == InteractionType.TemplateContent) + { + return View("Render", result ?? ""); + } + + return new ContentResult + { + Content = result, + ContentType = interactionData.DisplayMeta ?? "text/html" + }; } }