using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.IO.Compression; using System.Linq; using System.Reflection; using BTCPayServer.Abstractions.Contracts; using BTCPayServer.Configuration; using McMaster.NETCore.Plugins; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Logging; namespace BTCPayServer.Plugins { public static class PluginManager { public const string BTCPayPluginSuffix = ".btcpay"; private static readonly List _pluginAssemblies = new List(); private static readonly List _plugins = new List(); private static ILogger _logger; private static List<(PluginLoader, Assembly, IFileProvider)> loadedPlugins; public static bool IsExceptionByPlugin(Exception exception) { return _pluginAssemblies.Any(assembly => assembly?.FullName?.Contains(exception.Source!, StringComparison.OrdinalIgnoreCase) is true); } public static IMvcBuilder AddPlugins(this IMvcBuilder mvcBuilder, IServiceCollection serviceCollection, IConfiguration config, ILoggerFactory loggerFactory) { _logger = loggerFactory.CreateLogger(typeof(PluginManager)); var pluginsFolder = new DataDirectories().Configure(config).PluginDir; var plugins = new List(); serviceCollection.Configure(options => { options.Limits.MaxRequestBodySize = int.MaxValue; // if don't set default value is: 30 MB }); _logger.LogInformation($"Loading plugins from {pluginsFolder}"); Directory.CreateDirectory(pluginsFolder); ExecuteCommands(pluginsFolder); loadedPlugins = new List<(PluginLoader, Assembly, IFileProvider)>(); var systemPlugins = GetDefaultLoadedPluginAssemblies(); foreach (Assembly systemExtension in systemPlugins) { var detectedPlugins = GetAllPluginTypesFromAssembly(systemExtension).Select(GetPluginInstanceFromType); if (!detectedPlugins.Any()) { continue; } detectedPlugins = detectedPlugins.Select(plugin => { plugin.SystemPlugin = true; return plugin; }); loadedPlugins.Add((null, systemExtension, CreateEmbeddedFileProviderForAssembly(systemExtension))); plugins.AddRange(detectedPlugins); } var orderFilePath = Path.Combine(pluginsFolder, "order"); var availableDirs = Directory.GetDirectories(pluginsFolder); var orderedDirs = new List(); if (File.Exists(orderFilePath)) { var order = File.ReadLines(orderFilePath); foreach (var s in order) { if (availableDirs.Contains(s)) { orderedDirs.Add(s); } } orderedDirs.AddRange(availableDirs.Where(s => !orderedDirs.Contains(s))); } else { orderedDirs = availableDirs.ToList(); } var disabledPlugins = GetDisabledPlugins(pluginsFolder); foreach (var dir in orderedDirs) { var pluginName = Path.GetFileName(dir); var pluginFilePath = Path.Combine(dir, pluginName + ".dll"); if (disabledPlugins.Contains(pluginName)) { continue; } if (!File.Exists(pluginFilePath)) { _logger.LogError( $"Error when loading plugin {pluginName} - {pluginFilePath} does not exist"); continue; } try { var plugin = PluginLoader.CreateFromAssemblyFile( pluginFilePath, // create a plugin from for the .dll file config => { // this ensures that the version of MVC is shared between this app and the plugin config.PreferSharedTypes = true; config.IsUnloadable = true; }); mvcBuilder.AddPluginLoader(plugin); var pluginAssembly = plugin.LoadDefaultAssembly(); _pluginAssemblies.Add(pluginAssembly); _plugins.Add(plugin); var fileProvider = CreateEmbeddedFileProviderForAssembly(pluginAssembly); loadedPlugins.Add((plugin, pluginAssembly, fileProvider)); plugins.AddRange(GetAllPluginTypesFromAssembly(pluginAssembly) .Select(GetPluginInstanceFromType)); } catch (Exception e) { _logger.LogError(e, $"Error when loading plugin {pluginName}"); } } foreach (var plugin in plugins) { try { _logger.LogInformation( $"Adding and executing plugin {plugin.Identifier} - {plugin.Version}"); plugin.Execute(serviceCollection); serviceCollection.AddSingleton(plugin); } catch (Exception e) { _logger.LogError( $"Error when loading plugin {plugin.Identifier} - {plugin.Version}{Environment.NewLine}{e.Message}"); } } return mvcBuilder; } public static void UsePlugins(this IApplicationBuilder applicationBuilder) { foreach (var extension in applicationBuilder.ApplicationServices .GetServices()) { extension.Execute(applicationBuilder, applicationBuilder.ApplicationServices); } var webHostEnvironment = applicationBuilder.ApplicationServices.GetService(); List providers = new List() { webHostEnvironment.WebRootFileProvider }; providers.AddRange(loadedPlugins.Select(tuple => tuple.Item3)); webHostEnvironment.WebRootFileProvider = new CompositeFileProvider(providers); } private static Assembly[] GetDefaultLoadedPluginAssemblies() { return AppDomain.CurrentDomain.GetAssemblies() .ToArray(); } private static Type[] GetAllPluginTypesFromAssembly(Assembly assembly) { return assembly.GetTypes().Where(type => typeof(IBTCPayServerPlugin).IsAssignableFrom(type) && type != typeof(PluginService.AvailablePlugin) && !type.IsAbstract).ToArray(); } private static IBTCPayServerPlugin GetPluginInstanceFromType(Type type) { return (IBTCPayServerPlugin)Activator.CreateInstance(type, Array.Empty()); } private static IFileProvider CreateEmbeddedFileProviderForAssembly(Assembly assembly) { return new EmbeddedFileProvider(assembly); } private static void ExecuteCommands(string pluginsFolder) { var pendingCommands = GetPendingCommands(pluginsFolder); foreach (var command in pendingCommands) { ExecuteCommand(command, pluginsFolder); } File.Delete(Path.Combine(pluginsFolder, "commands")); } private static void ExecuteCommand((string command, string extension) command, string pluginsFolder, bool ignoreOrder = false) { var dirName = Path.Combine(pluginsFolder, command.extension); switch (command.command) { case "update": ExecuteCommand(("enable", command.extension), pluginsFolder, true); ExecuteCommand(("delete", command.extension), pluginsFolder, true); ExecuteCommand(("install", command.extension), pluginsFolder, true); break; case "delete": ExecuteCommand(("enable", command.extension), pluginsFolder, true); if (Directory.Exists(dirName)) { Directory.Delete(dirName, true); if (!ignoreOrder && File.Exists(Path.Combine(pluginsFolder, "order"))) { var orders = File.ReadAllLines(Path.Combine(pluginsFolder, "order")); File.WriteAllLines(Path.Combine(pluginsFolder, "order"), orders.Where(s => s != command.extension)); } } break; case "install": ExecuteCommand(("enable", command.extension), pluginsFolder, true); var fileName = dirName + BTCPayPluginSuffix; if (File.Exists(fileName)) { ZipFile.ExtractToDirectory(fileName, dirName, true); if (!ignoreOrder) { File.AppendAllLines(Path.Combine(pluginsFolder, "order"), new[] { command.extension }); } File.Delete(fileName); } break; case "disable": if (Directory.Exists(dirName)) { if (File.Exists(Path.Combine(pluginsFolder, "disabled"))) { var disabled = File.ReadAllLines(Path.Combine(pluginsFolder, "disabled")); if (!disabled.Contains(command.extension)) { File.AppendAllLines(Path.Combine(pluginsFolder, "disabled"), new[] { command.extension }); } } else { File.AppendAllLines(Path.Combine(pluginsFolder, "disabled"), new[] { command.extension }); } } break; case "enable": if (File.Exists(Path.Combine(pluginsFolder, "disabled"))) { var disabled = File.ReadAllLines(Path.Combine(pluginsFolder, "disabled")); if (disabled.Contains(command.extension)) { File.WriteAllLines(Path.Combine(pluginsFolder, "disabled"), disabled.Where(s => s != command.extension)); } } break; } } public static (string command, string plugin)[] GetPendingCommands(string pluginsFolder) { if (!File.Exists(Path.Combine(pluginsFolder, "commands"))) return Array.Empty<(string command, string plugin)>(); var commands = File.ReadAllLines(Path.Combine(pluginsFolder, "commands")); return commands.Select(s => { var split = s.Split(':'); return (split[0].ToLower(CultureInfo.InvariantCulture), split[1]); }).ToArray(); } public static void QueueCommands(string pluginsFolder, params (string action, string plugin)[] commands) { File.AppendAllLines(Path.Combine(pluginsFolder, "commands"), commands.Select((tuple) => $"{tuple.action}:{tuple.plugin}")); } public static void CancelCommands(string pluginDir, string plugin) { var cmds = GetPendingCommands(pluginDir).Where(tuple => !tuple.plugin.Equals(plugin, StringComparison.InvariantCultureIgnoreCase)).ToArray(); File.Delete(Path.Combine(pluginDir, "commands")); QueueCommands(pluginDir, cmds); } public static void DisablePlugin(string pluginDir, string plugin) { QueueCommands(pluginDir, ("disable", plugin)); } public static void Unload() { _plugins.ForEach(loader => loader.Dispose()); } public static string[] GetDisabledPlugins(string pluginsFolder) { var disabledFilePath = Path.Combine(pluginsFolder, "disabled"); if (File.Exists(disabledFilePath)) { return File.ReadLines(disabledFilePath).ToArray(); } return Array.Empty(); } } }