mirror of
https://github.com/RaidMax/IW4M-Admin.git
synced 2025-06-07 21:58:06 -05:00
* Update codebase to target .NET 8.0 and improve JSON serialization This commit switches our target framework from .NET 6.0 to .NET 8.0 and replaces Newtonsoft.Json with System.Text.Json for serialization. The JsonConverter classes have been updated to support the new JSON model and some enhancements were applied to the codebase such as fixing a command property and updating various package references. * Align with Develop * Update SharedLibraryCore package version The version of the SharedLibraryCore package reference has been updated across multiple projects from '2024.2.4.85' to '2024.2.5.9'. Meanwhile, version within SharedLibraryCore.csproj has been changed from '2024.02.04.085' to '2024.01.01.1'. Changes also include removal of .NET 8 requirement notice and reenabling of status upload to master communicator. * Update properties in IRConParser and IRConParserConfiguration to be settable The properties in the `IRConParser` and `IRConParserConfiguration` interfaces were updated to include setters. Previously, the properties in these interfaces were read-only. This change allows for the modifications and extensions of properties defined, thereby bolstering flexibility for the handling of games and parsers. * Replace RestEase with Refit in API usage Refit has been implemented as a replacement for RestEase in all API calls. As such, all related code, parameters and imports have been adjusted to function with Refit. Logic has also been added to handle certain Refit-specific behaviours. Occurrences of the RestEase package have been removed from the project. * Enable auto-redirect in HttpClient The HttpClient instance used in Application/Main.cs has been modified to automatically follow redirect responses. This was accomplished by adding "AllowAutoRedirect = true" to the HttpClientHandler used when creating the HttpClient. --------- Co-authored-by: Amos <amos2580@hotmail.co.uk>
569 lines
21 KiB
C#
569 lines
21 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
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;
|
|
using Reference = Data.Models.Reference;
|
|
|
|
namespace IW4MAdmin.Application.Plugin.Script
|
|
{
|
|
/// <summary>
|
|
/// implementation of IPlugin
|
|
/// used to proxy script plugin requests
|
|
/// </summary>
|
|
public class ScriptPlugin : IPlugin
|
|
{
|
|
public string Name { get; set; }
|
|
|
|
public float Version { get; set; }
|
|
|
|
public string Author { get; set; }
|
|
|
|
/// <summary>
|
|
/// indicates if the plugin is a parser
|
|
/// </summary>
|
|
public bool IsParser { get; private set; }
|
|
|
|
public FileSystemWatcher Watcher { get; }
|
|
|
|
private Engine _scriptEngine;
|
|
private readonly string _fileName;
|
|
private readonly SemaphoreSlim _onProcessing = new(1, 1);
|
|
private bool _successfullyLoaded;
|
|
private readonly List<string> _registeredCommandNames;
|
|
private readonly ILogger _logger;
|
|
|
|
public ScriptPlugin(ILogger logger, string filename, string workingDirectory = null)
|
|
{
|
|
_logger = logger;
|
|
_fileName = filename;
|
|
Watcher = new FileSystemWatcher
|
|
{
|
|
Path = workingDirectory ?? $"{Utilities.OperatingDirectory}Plugins{Path.DirectorySeparatorChar}",
|
|
NotifyFilter = NotifyFilters.LastWrite,
|
|
Filter = _fileName.Split(Path.DirectorySeparatorChar).Last()
|
|
};
|
|
|
|
Watcher.EnableRaisingEvents = true;
|
|
_registeredCommandNames = new List<string>();
|
|
}
|
|
|
|
~ScriptPlugin()
|
|
{
|
|
Watcher.Dispose();
|
|
_onProcessing.Dispose();
|
|
}
|
|
|
|
public async Task Initialize(IManager manager, IScriptCommandFactory scriptCommandFactory,
|
|
IScriptPluginServiceResolver serviceResolver, IConfigurationHandlerV2<ScriptPluginConfiguration> configHandler)
|
|
{
|
|
try
|
|
{
|
|
await _onProcessing.WaitAsync();
|
|
|
|
// for some reason we get an event trigger when the file is not finished being modified.
|
|
// this must have been a change in .NET CORE 3.x
|
|
// so if the new file is empty we can't process it yet
|
|
if (new FileInfo(_fileName).Length == 0L)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var firstRun = _scriptEngine == null;
|
|
|
|
// it's been loaded before so we need to call the unload event
|
|
if (!firstRun)
|
|
{
|
|
await OnUnloadAsync();
|
|
|
|
foreach (var commandName in _registeredCommandNames)
|
|
{
|
|
_logger.LogDebug("Removing plugin registered command {Command}", commandName);
|
|
manager.RemoveCommandByName(commandName);
|
|
}
|
|
|
|
_registeredCommandNames.Clear();
|
|
}
|
|
|
|
_successfullyLoaded = false;
|
|
string script;
|
|
|
|
await using (var stream =
|
|
new FileStream(_fileName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
|
{
|
|
using (var reader = new StreamReader(stream, Encoding.Default))
|
|
{
|
|
script = await reader.ReadToEndAsync();
|
|
}
|
|
}
|
|
|
|
_scriptEngine?.Dispose();
|
|
_scriptEngine = new Engine(cfg =>
|
|
cfg.AddExtensionMethods(typeof(Utilities), typeof(Enumerable), typeof(Queryable),
|
|
typeof(ScriptPluginExtensions))
|
|
.AllowClr(new[]
|
|
{
|
|
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 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);
|
|
dynamic pluginObject = _scriptEngine.Evaluate("plugin").ToObject();
|
|
|
|
Author = pluginObject.author;
|
|
Name = pluginObject.name;
|
|
Version = (float)pluginObject.version;
|
|
|
|
var commands = JsValue.Undefined;
|
|
try
|
|
{
|
|
commands = _scriptEngine.Evaluate("commands");
|
|
}
|
|
catch (JavaScriptException)
|
|
{
|
|
// ignore because commands aren't defined;
|
|
}
|
|
|
|
if (commands != JsValue.Undefined)
|
|
{
|
|
try
|
|
{
|
|
foreach (var command in GenerateScriptCommands(commands, scriptCommandFactory))
|
|
{
|
|
_logger.LogDebug("Adding plugin registered command {CommandName}", command.Name);
|
|
manager.AddAdditionalCommand(command);
|
|
_registeredCommandNames.Add(command.Name);
|
|
}
|
|
}
|
|
|
|
catch (RuntimeBinderException e)
|
|
{
|
|
throw new PluginException($"Not all required fields were found: {e.Message}")
|
|
{ PluginFile = _fileName };
|
|
}
|
|
}
|
|
|
|
async Task<bool> OnLoadTask()
|
|
{
|
|
await OnLoadAsync(manager);
|
|
return true;
|
|
}
|
|
|
|
var loadComplete = false;
|
|
|
|
try
|
|
{
|
|
if (pluginObject.isParser)
|
|
{
|
|
loadComplete = await OnLoadTask();
|
|
IsParser = true;
|
|
var eventParser = (IEventParser)_scriptEngine.Evaluate("eventParser").ToObject();
|
|
var rconParser = (IRConParser)_scriptEngine.Evaluate("rconParser").ToObject();
|
|
manager.AdditionalEventParsers.Add(eventParser);
|
|
manager.AdditionalRConParsers.Add(rconParser);
|
|
}
|
|
}
|
|
|
|
catch (RuntimeBinderException)
|
|
{
|
|
var configWrapper = new ScriptPluginConfigurationWrapper(Name, _scriptEngine, configHandler);
|
|
|
|
if (!loadComplete)
|
|
{
|
|
_scriptEngine.SetValue("_configHandler", configWrapper);
|
|
loadComplete = await OnLoadTask();
|
|
}
|
|
}
|
|
|
|
if (!firstRun && !loadComplete)
|
|
{
|
|
loadComplete = await OnLoadTask();
|
|
}
|
|
|
|
_successfullyLoaded = loadComplete;
|
|
}
|
|
catch (JavaScriptException ex)
|
|
{
|
|
_logger.LogError(ex,
|
|
"Encountered JavaScript runtime error while executing {MethodName} for script plugin {Plugin} at {@LocationInfo} StackTrace={StackTrace}",
|
|
nameof(Initialize), Path.GetFileName(_fileName), ex.Location, ex.JavaScriptStackTrace);
|
|
|
|
throw new PluginException("An error occured while initializing 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}",
|
|
nameof(Initialize), _fileName, jsEx.Location, jsEx.JavaScriptStackTrace);
|
|
|
|
throw new PluginException("An error occured while initializing script plugin");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex,
|
|
"Encountered JavaScript runtime error while executing {MethodName} for script plugin {Plugin}",
|
|
nameof(OnLoadAsync), Path.GetFileName(_fileName));
|
|
|
|
throw new PluginException("An error occured while executing action for script plugin");
|
|
}
|
|
finally
|
|
{
|
|
if (_onProcessing.CurrentCount == 0)
|
|
{
|
|
_onProcessing.Release(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
public async Task OnEventAsync(GameEvent gameEvent, Server server)
|
|
{
|
|
if (!_successfullyLoaded)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var shouldRelease = false;
|
|
|
|
try
|
|
{
|
|
await _onProcessing.WaitAsync(Utilities.DefaultCommandTimeout / 2);
|
|
shouldRelease = true;
|
|
WrapJavaScriptErrorHandling(() =>
|
|
{
|
|
_scriptEngine.SetValue("_gameEvent", gameEvent);
|
|
_scriptEngine.SetValue("_server", server);
|
|
_scriptEngine.SetValue("_IW4MAdminClient", Utilities.IW4MAdminClient(server));
|
|
return _scriptEngine.Evaluate("plugin.onEventAsync(_gameEvent, _server)");
|
|
}, new { EventType = gameEvent.Type }, server);
|
|
}
|
|
finally
|
|
{
|
|
if (_onProcessing.CurrentCount == 0 && shouldRelease)
|
|
{
|
|
_onProcessing.Release(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
public Task OnLoadAsync(IManager manager)
|
|
{
|
|
_logger.LogDebug("OnLoad executing for {Name}", Name);
|
|
|
|
WrapJavaScriptErrorHandling(() =>
|
|
{
|
|
_scriptEngine.SetValue("_manager", manager);
|
|
return _scriptEngine.Evaluate("plugin.onLoadAsync(_manager)");
|
|
});
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task OnTickAsync(Server server)
|
|
{
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public async Task OnUnloadAsync()
|
|
{
|
|
if (!_successfullyLoaded)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
await _onProcessing.WaitAsync();
|
|
|
|
_logger.LogDebug("OnUnload executing for {Name}", Name);
|
|
|
|
WrapJavaScriptErrorHandling(() => _scriptEngine.Evaluate("plugin.onUnloadAsync()"));
|
|
}
|
|
finally
|
|
{
|
|
if (_onProcessing.CurrentCount == 0)
|
|
{
|
|
_onProcessing.Release(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
public T ExecuteAction<T>(Delegate action, CancellationToken token, params object[] param)
|
|
{
|
|
var shouldRelease = false;
|
|
|
|
try
|
|
{
|
|
using var forceTimeout = new CancellationTokenSource(5000);
|
|
using var combined = CancellationTokenSource.CreateLinkedTokenSource(forceTimeout.Token, token);
|
|
_onProcessing.Wait(combined.Token);
|
|
shouldRelease = true;
|
|
|
|
_logger.LogDebug("Executing action for {Name}", Name);
|
|
|
|
return WrapJavaScriptErrorHandling(T() =>
|
|
{
|
|
var args = param.Select(p => JsValue.FromObject(_scriptEngine, p)).ToArray();
|
|
var result = action.DynamicInvoke(JsValue.Undefined, args);
|
|
return (T)(result as JsValue)?.ToObject();
|
|
},
|
|
new
|
|
{
|
|
Params = string.Join(", ",
|
|
param?.Select(eachParam => $"Type={eachParam?.GetType().Name} Value={eachParam}") ??
|
|
Enumerable.Empty<string>())
|
|
});
|
|
}
|
|
finally
|
|
{
|
|
if (_onProcessing.CurrentCount == 0 && shouldRelease)
|
|
{
|
|
_onProcessing.Release(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
public T WrapDelegate<T>(Delegate act, CancellationToken token, params object[] args)
|
|
{
|
|
var shouldRelease = false;
|
|
|
|
try
|
|
{
|
|
using var forceTimeout = new CancellationTokenSource(5000);
|
|
using var combined = CancellationTokenSource.CreateLinkedTokenSource(forceTimeout.Token, token);
|
|
_onProcessing.Wait(combined.Token);
|
|
shouldRelease = true;
|
|
|
|
_logger.LogDebug("Wrapping delegate action for {Name}", Name);
|
|
|
|
return WrapJavaScriptErrorHandling(
|
|
T() => (T)(act.DynamicInvoke(JsValue.Null,
|
|
args.Select(arg => JsValue.FromObject(_scriptEngine, arg)).ToArray()) as ObjectWrapper)
|
|
?.ToObject(),
|
|
new
|
|
{
|
|
Params = string.Join(", ",
|
|
args?.Select(eachParam => $"Type={eachParam?.GetType().Name} Value={eachParam}") ??
|
|
Enumerable.Empty<string>())
|
|
});
|
|
}
|
|
finally
|
|
{
|
|
if (_onProcessing.CurrentCount == 0 && shouldRelease)
|
|
{
|
|
_onProcessing.Release(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// finds declared script commands in the script plugin
|
|
/// </summary>
|
|
/// <param name="commands">commands value from jint parser</param>
|
|
/// <param name="scriptCommandFactory">factory to create the command from</param>
|
|
/// <returns></returns>
|
|
private IEnumerable<IManagerCommand> GenerateScriptCommands(JsValue commands,
|
|
IScriptCommandFactory scriptCommandFactory)
|
|
{
|
|
var commandList = new List<IManagerCommand>();
|
|
|
|
// go through each defined command
|
|
foreach (var command in commands.AsArray())
|
|
{
|
|
dynamic dynamicCommand = command.ToObject();
|
|
string name = dynamicCommand.name;
|
|
string alias = dynamicCommand.alias;
|
|
string description = dynamicCommand.description;
|
|
|
|
if (dynamicCommand.permission is Data.Models.Client.EFClient.Permission perm)
|
|
{
|
|
dynamicCommand.permission = perm.ToString();
|
|
}
|
|
|
|
string permission = dynamicCommand.permission;
|
|
List<Reference.Game> supportedGames = null;
|
|
var targetRequired = false;
|
|
|
|
var args = new List<CommandArgument>();
|
|
dynamic arguments = null;
|
|
|
|
try
|
|
{
|
|
arguments = dynamicCommand.arguments;
|
|
}
|
|
|
|
catch (RuntimeBinderException)
|
|
{
|
|
// arguments are optional
|
|
}
|
|
|
|
try
|
|
{
|
|
targetRequired = dynamicCommand.targetRequired;
|
|
}
|
|
|
|
catch (RuntimeBinderException)
|
|
{
|
|
// arguments are optional
|
|
}
|
|
|
|
if (arguments != null)
|
|
{
|
|
foreach (var arg in dynamicCommand.arguments)
|
|
{
|
|
args.Add(new CommandArgument { Name = arg.name, Required = (bool)arg.required });
|
|
}
|
|
}
|
|
|
|
try
|
|
{
|
|
foreach (var game in dynamicCommand.supportedGames)
|
|
{
|
|
supportedGames ??= new List<Reference.Game>();
|
|
supportedGames.Add(Enum.Parse(typeof(Reference.Game), game.ToString()));
|
|
}
|
|
}
|
|
catch (RuntimeBinderException)
|
|
{
|
|
// supported games is optional
|
|
}
|
|
|
|
async Task Execute(GameEvent gameEvent)
|
|
{
|
|
try
|
|
{
|
|
await _onProcessing.WaitAsync();
|
|
|
|
_scriptEngine.SetValue("_event", gameEvent);
|
|
var jsEventObject = _scriptEngine.Evaluate("_event");
|
|
|
|
dynamicCommand.execute.Target.Invoke(_scriptEngine, jsEventObject);
|
|
}
|
|
|
|
catch (JavaScriptException ex)
|
|
{
|
|
using (LogContext.PushProperty("Server", gameEvent.Owner?.ToString()))
|
|
{
|
|
_logger.LogError(ex, "Could not execute command action for {Filename} {@Location}",
|
|
Path.GetFileName(_fileName), ex.Location);
|
|
}
|
|
|
|
throw new PluginException("A runtime error occured while executing action for script plugin");
|
|
}
|
|
|
|
catch (Exception ex)
|
|
{
|
|
using (LogContext.PushProperty("Server", gameEvent.Owner?.ToString()))
|
|
{
|
|
_logger.LogError(ex,
|
|
"Could not execute command action for script plugin {FileName}",
|
|
Path.GetFileName(_fileName));
|
|
}
|
|
|
|
throw new PluginException("An error occured while executing action for script plugin");
|
|
}
|
|
|
|
finally
|
|
{
|
|
if (_onProcessing.CurrentCount == 0)
|
|
{
|
|
_onProcessing.Release(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
commandList.Add(scriptCommandFactory.CreateScriptCommand(name, alias, description, permission,
|
|
targetRequired, args, Execute, supportedGames));
|
|
}
|
|
|
|
return commandList;
|
|
}
|
|
|
|
private T WrapJavaScriptErrorHandling<T>(Func<T> work, object additionalData = null, Server server = null,
|
|
[CallerMemberName] string methodName = "")
|
|
{
|
|
using (LogContext.PushProperty("Server", server?.ToString()))
|
|
{
|
|
try
|
|
{
|
|
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);
|
|
|
|
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);
|
|
|
|
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));
|
|
|
|
throw new PluginException("An error occured while executing action for script plugin");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public class PermissionLevelToStringConverter : IObjectConverter
|
|
{
|
|
public bool TryConvert(Engine engine, object value, out JsValue result)
|
|
{
|
|
if (value is Data.Models.Client.EFClient.Permission)
|
|
{
|
|
result = value.ToString();
|
|
return true;
|
|
}
|
|
|
|
result = JsValue.Null;
|
|
return false;
|
|
}
|
|
}
|
|
}
|