2023-04-04 19:24:13 -04:00
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 ) ;
2023-04-05 10:54:57 -04:00
#pragma warning disable CS8974
2023-04-04 19:24:13 -04:00
var initResult = ScriptEngine . Call ( "init" , JsValue . FromObject ( ScriptEngine , EventCallbackWrapper ) ,
JsValue . FromObject ( ScriptEngine , _pluginServiceResolver ) ,
JsValue . FromObject ( ScriptEngine , _scriptPluginConfigurationWrapper ) ,
JsValue . FromObject ( ScriptEngine , new ScriptPluginHelper ( manager , this ) ) ) ;
2023-04-05 10:54:57 -04:00
#pragma warning restore CS8974
2023-04-04 19:24:13 -04:00
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 )
. 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 < 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 ;
}
}
}