IW4M-Admin/Application/Plugin/Script/ScriptPluginV2.cs

580 lines
23 KiB
C#

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<ScriptPluginV2> _logger;
private readonly IScriptPluginServiceResolver _pluginServiceResolver;
private readonly IScriptCommandFactory _scriptCommandFactory;
private readonly IConfigurationHandlerV2<ScriptPluginConfiguration> _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<string> _registeredCommandNames = new();
private readonly List<string> _registeredInteractions = new();
private readonly Dictionary<MethodInfo, List<object>> _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<Reference.Game> 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<string, JavascriptEngine> ActiveEngines = new();
public ScriptPluginV2(string fileName, ILogger<ScriptPluginV2> logger,
IScriptPluginServiceResolver pluginServiceResolver, IScriptCommandFactory scriptCommandFactory,
IConfigurationHandlerV2<ScriptPluginConfiguration> 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<Engine> 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);
#pragma warning disable CS8974
var initResult = ScriptEngine.Call("init", JsValue.FromObject(ScriptEngine, EventCallbackWrapper),
JsValue.FromObject(ScriptEngine, _pluginServiceResolver),
JsValue.FromObject(ScriptEngine, _scriptPluginConfigurationWrapper),
JsValue.FromObject(ScriptEngine, new ScriptPluginHelper(manager, this)));
#pragma warning restore CS8974
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<IInteractionData> 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, typeof(ScriptPluginWebRequest).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);
_scriptPluginConfigurationWrapper.ConfigurationUpdated += (configValue, callbackAction) =>
{
WrapJavaScriptErrorHandling(() =>
{
callbackAction.DynamicInvoke(JsValue.Undefined, new[] { configValue });
return Task.CompletedTask;
}, _logger, _fileName, _onProcessingScript);
};
}
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<object> { eventWrapper });
}
else
{
_registeredEvents[eventRemoveMethod].Add(eventWrapper);
}
}
private static Func<TEventType, CancellationToken, Task> BuildEventWrapper<TEventType>(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<TResultType>(Func<TResultType> 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<ScriptPluginCommandDetails>();
if (HasProperty(source, "commands") && source.commands is dynamic[])
{
commandDetails = ((dynamic[])source.commands).Select(command =>
{
var commandArgs = Array.Empty<CommandArgument>();
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<object>
? ((IEnumerable<object>)command.supportedGames).Where(game => game?.ToString() is not null)
.Select(game =>
Enum.Parse<Reference.Game>(game.ToString()!))
: Array.Empty<Reference.Game>();
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<ScriptPluginInteractionDetails>();
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<string, object>)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;
}
}
}