BTCPay Extensions Part 2 (#2001)

* BTCPay Extensions Part 2

This PR cleans up the extension system a bit in that:
 * It renames the test extension to a more uniform name
 * Allows yo uto have system extensions, which are extensions but bundled by default with the release (and cannot be removed)
 * Adds a tool to help you generate an extension package from a csproj
 * Refactors the UI extension points to a view component
 * Moves some more interfaces to the Abstractions csproj

* Rename to plugins
This commit is contained in:
Andrew Camilleri 2020-10-21 14:02:20 +02:00 committed by GitHub
parent 362ba21567
commit 5979fe5eef
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
53 changed files with 860 additions and 573 deletions

1
.gitignore vendored
View File

@ -298,3 +298,4 @@ BTCPayServer/wwwroot/bundles/*
!.vscode/extensions.json
BTCPayServer/testpwd
.DS_Store
Packed Plugins

View File

@ -0,0 +1,6 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Build and pack plugins" type="CompoundRunConfigurationType">
<toRun name="Pack Test Plugin" type="DotNetProject" />
<method v="2" />
</configuration>
</component>

View File

@ -0,0 +1,21 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Pack Test Plugin" type="DotNetProject" factoryName=".NET Project" singleton="false">
<option name="EXE_PATH" value="$PROJECT_DIR$/BTCPayServer.PluginPacker/bin/Debug/netcoreapp3.1/BTCPayServer.PluginPacker.dll" />
<option name="PROGRAM_PARAMETERS" value="../../../../BTCPayServer.Plugins.Test\bin\Debug\netcoreapp3.1 BTCPayServer.Plugins.Test &quot;../../../../Packed Plugins&quot;" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/BTCPayServer.PluginPacker/bin/Debug/netcoreapp3.1" />
<option name="PASS_PARENT_ENVS" value="1" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/BTCPayServer.PluginPacker/BTCPayServer.PluginPacker.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="1" />
<option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value=".NETCoreApp,Version=v3.1" />
<method v="2">
<option name="Build" default="false" projectName="BTCPayServer.Plugins.Test" projectPath="C:\Git\btcpayserver\BTCPayServer.Plugins.Test\BTCPayServer.Plugins.Test.csproj" />
<option name="Build" />
</method>
</configuration>
</component>

View File

@ -1,16 +1,20 @@
using System;
using System.Text.Json.Serialization;
using BTCPayServer.Abstractions.Converters;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
namespace BTCPayServer.Contracts
{
public interface IBTCPayServerExtension
public interface IBTCPayServerPlugin
{
public string Identifier { get; }
string Name { get; }
[JsonConverter(typeof(VersionConverter))]
Version Version { get; }
string Description { get; }
bool SystemPlugin { get; set; }
string[] Dependencies { get; }
void Execute(IApplicationBuilder applicationBuilder, IServiceProvider applicationBuilderApplicationServices);
void Execute(IServiceCollection applicationBuilder);
}

View File

@ -0,0 +1,12 @@
using System.Threading;
using System.Threading.Tasks;
namespace BTCPayServer.Services
{
public interface ISettingsRepository
{
Task<T> GetSettingAsync<T>(string name = null);
Task UpdateSetting<T>(T obj, string name = null);
Task<T> WaitSettingsChanged<T>(CancellationToken cancellationToken = default);
}
}

View File

@ -1,15 +1,15 @@
namespace BTCPayServer.Contracts
{
public interface INavExtension
public interface IUIExtension
{
string Partial { get; }
string Location { get; }
}
public class NavExtension: INavExtension
public class UIExtension: IUIExtension
{
public NavExtension(string partial, string location)
public UIExtension(string partial, string location)
{
Partial = partial;
Location = location;

View File

@ -0,0 +1,19 @@
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace BTCPayServer.Abstractions.Converters
{
public class VersionConverter : JsonConverter<Version>
{
public override Version Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return Version.Parse(reader.GetString());
}
public override void Write(Utf8JsonWriter writer, Version value, JsonSerializerOptions options)
{
writer.WriteStringValue(value.ToString());
}
}
}

View File

@ -0,0 +1,20 @@
using System.Text.Json;
using BTCPayServer.Models;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
namespace BTCPayServer
{
public static class SetStatusMessageModelExtensions
{
public static void SetStatusMessageModel(this ITempDataDictionary tempData, StatusMessageModel statusMessage)
{
if (statusMessage == null)
{
tempData.Remove("StatusMessageModel");
return;
}
tempData["StatusMessageModel"] = JsonSerializer.Serialize(statusMessage, new JsonSerializerOptions());
}
}
}

View File

@ -0,0 +1,36 @@
using System;
using System.Reflection;
using BTCPayServer.Contracts;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
namespace BTCPayServer.Models
{
public abstract class BaseBTCPayServerPlugin : IBTCPayServerPlugin
{
public abstract string Identifier { get; }
public abstract string Name { get; }
public virtual Version Version
{
get
{
return Assembly.GetAssembly(GetType())?.GetName().Version ?? new Version(1, 0, 0, 0);
}
}
public abstract string Description { get; }
public bool SystemPlugin { get; set; }
public bool SystemExtension { get; set; }
public virtual string[] Dependencies { get; } = Array.Empty<string>();
public virtual void Execute(IApplicationBuilder applicationBuilder,
IServiceProvider applicationBuilderApplicationServices)
{
}
public virtual void Execute(IServiceCollection applicationBuilder)
{
}
}
}

View File

@ -24,7 +24,7 @@ namespace BTCPayServer
var settings = new BTCPayDefaultSettings();
_Settings.Add(chainType, settings);
settings.DefaultDataDirectory = StandardConfiguration.DefaultDataDirectory.GetDirectory("BTCPayServer", NBXplorerDefaultSettings.GetFolderName(chainType));
settings.DefaultExtensionDirectory =
settings.DefaultPluginDirectory =
StandardConfiguration.DefaultDataDirectory.GetDirectory("BTCPayServer", "Extensions");
settings.DefaultConfigurationFile = Path.Combine(settings.DefaultDataDirectory, "settings.config");
settings.DefaultPort = (chainType == NetworkType.Mainnet ? 23000 :
@ -41,7 +41,7 @@ namespace BTCPayServer
}
public string DefaultDataDirectory { get; set; }
public string DefaultExtensionDirectory { get; set; }
public string DefaultPluginDirectory { get; set; }
public string DefaultConfigurationFile { get; set; }
public int DefaultPort { get; set; }
}

View File

@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp3.1</TargetFramework>
<AssemblyVersion>1.0.0.1</AssemblyVersion>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\BTCPayServer.Abstractions\BTCPayServer.Abstractions.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,50 @@
using System;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using BTCPayServer.Contracts;
namespace BTCPayServer.PluginPacker
{
class Program
{
static void Main(string[] args)
{
var directory = args[0];
var name = args[1];
var outputDir = args[2];
var outputFile = Path.Combine(outputDir, name);
var rootDLLPath = Path.Combine(directory, name +".dll");
if (!File.Exists(rootDLLPath) )
{
throw new Exception($"{rootDLLPath} could not be found");
}
var assembly = Assembly.LoadFrom(rootDLLPath);
var extension = GetAllExtensionTypesFromAssembly(assembly).FirstOrDefault();
if (extension is null)
{
throw new Exception($"{rootDLLPath} is not a valid plugin");
}
var loadedPlugin = (IBTCPayServerPlugin)Activator.CreateInstance(extension);
var json = JsonSerializer.Serialize(loadedPlugin);
Directory.CreateDirectory(outputDir);
if (File.Exists(outputFile + ".btcpay"))
{
File.Delete(outputFile + ".btcpay");
}
ZipFile.CreateFromDirectory(directory, outputFile + ".btcpay", CompressionLevel.Optimal, false);
File.WriteAllText(outputFile + ".btcpay.json", json);
}
private static Type[] GetAllExtensionTypesFromAssembly(Assembly assembly)
{
return assembly.GetTypes().Where(type =>
typeof(IBTCPayServerPlugin).IsAssignableFrom(type) &&
!type.IsAbstract).ToArray();
}
}
}

View File

@ -0,0 +1,4 @@
This tool makes it easy to create a BTCPay plugin package. To create a package you must:
* Build your BTCPay Plugin project
* open a terminal in this project
* run `dotnet run PATH_TO_PLUGIN_BUILD_DIRECTORY NAME_OF_PLUGIN BUILT_PACKAGE_OUTPUT_DIRECTORY`

View File

@ -6,7 +6,7 @@ using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace BTCPayServer.Test
namespace BTCPayServer.Plugins.Test
{
public class ApplicationPartsLogger : IHostedService
{

View File

@ -4,6 +4,7 @@
<AddRazorSupportForMvc>true</AddRazorSupportForMvc>
<PreserveCompilationContext>false</PreserveCompilationContext>
<GenerateEmbeddedFilesManifest>true</GenerateEmbeddedFilesManifest>
<AssemblyVersion>1.0.0</AssemblyVersion>
</PropertyGroup>
<ItemGroup>

View File

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

@ -0,0 +1,19 @@
using BTCPayServer.Contracts;
using BTCPayServer.Models;
using Microsoft.Extensions.DependencyInjection;
namespace BTCPayServer.Plugins.Test
{
public class TestPlugin: BaseBTCPayServerPlugin
{
public override string Identifier { get; } = "BTCPayServer.Plugins.Test";
public override string Name { get; } = "Test Plugin!";
public override string Description { get; } = "This is a description of the loaded test extension!";
public override void Execute(IServiceCollection services)
{
services.AddSingleton<IUIExtension>(new UIExtension("TestExtensionNavExtension", "header-nav"));
services.AddHostedService<ApplicationPartsLogger>();
}
}
}

View File

@ -1,6 +1,6 @@
using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.Test
namespace BTCPayServer.Plugins.Test
{
[Route("extensions/test")]
public class TestExtensionController : Controller

View File

@ -1,25 +0,0 @@
using System;
using BTCPayServer.Contracts;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
namespace BTCPayServer.Test
{
public class TestExtension: IBTCPayServerExtension
{
public string Identifier { get; } = "BTCPayServer.Test";
public string Name { get; } = "Test Plugin!";
public Version Version { get; } = new Version(1,0,0,0);
public string Description { get; } = "This is a description of the loaded test extension!";
public void Execute(IApplicationBuilder applicationBuilder, IServiceProvider applicationBuilderApplicationServices)
{
}
public void Execute(IServiceCollection services)
{
services.AddSingleton<INavExtension>(new NavExtension("TestExtensionNavExtension", "header-nav"));
services.AddHostedService<ApplicationPartsLogger>();
}
}
}

View File

@ -1,160 +0,0 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Reflection;
using BTCPayServer.Configuration;
using BTCPayServer.Contracts;
using McMaster.NETCore.Plugins;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Logging;
namespace BTCPayServer
{
public static class ExtensionManager
{
public const string BTCPayExtensionSuffix =".btcpay";
private static readonly List<Assembly> _pluginAssemblies = new List<Assembly>();
private static ILogger _logger;
public static IMvcBuilder AddExtensions(this IMvcBuilder mvcBuilder, IServiceCollection serviceCollection,
IConfiguration config, ILoggerFactory loggerFactory)
{
_logger = loggerFactory.CreateLogger(typeof(ExtensionManager));
var extensionsFolder = config.GetExtensionDir(DefaultConfiguration.GetNetworkType(config));
var extensions = new List<IBTCPayServerExtension>();
_logger.LogInformation($"Loading extensions from {extensionsFolder}");
Directory.CreateDirectory(extensionsFolder);
ExecuteCommands(extensionsFolder);
List<(PluginLoader, Assembly, IFileProvider)> plugins = new List<(PluginLoader, Assembly, IFileProvider)>();
foreach (var dir in Directory.GetDirectories(extensionsFolder))
{
var pluginName = Path.GetFileName(dir);
var plugin = PluginLoader.CreateFromAssemblyFile(
Path.Combine(dir, pluginName + ".dll"), // 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);
mvcBuilder.AddPluginLoader(plugin);
var pluginAssembly = plugin.LoadDefaultAssembly();
_pluginAssemblies.Add(pluginAssembly);
var fileProvider = CreateEmbeddedFileProviderForAssembly(pluginAssembly);
plugins.Add((plugin, pluginAssembly, fileProvider));
extensions.AddRange(GetAllExtensionTypesFromAssembly(pluginAssembly)
.Select(GetExtensionInstanceFromType));
}
foreach (var extension in extensions)
{
_logger.LogInformation($"Adding and executing extension {extension.Identifier} - {extension.Version}");
serviceCollection.AddSingleton(extension);
extension.Execute(serviceCollection);
}
return mvcBuilder;
}
public static void UseExtensions(this IApplicationBuilder applicationBuilder)
{
foreach (var extension in applicationBuilder.ApplicationServices
.GetServices<IBTCPayServerExtension>())
{
extension.Execute(applicationBuilder,
applicationBuilder.ApplicationServices);
}
var webHostEnvironment = applicationBuilder.ApplicationServices.GetService<IWebHostEnvironment>();
List<IFileProvider> providers = new List<IFileProvider>() {webHostEnvironment.WebRootFileProvider};
providers.AddRange(
_pluginAssemblies
.Select(CreateEmbeddedFileProviderForAssembly));
webHostEnvironment.WebRootFileProvider = new CompositeFileProvider(providers);
}
private static Type[] GetAllExtensionTypesFromAssembly(Assembly assembly)
{
return assembly.GetTypes().Where(type =>
typeof(IBTCPayServerExtension).IsAssignableFrom(type) &&
!type.IsAbstract).ToArray();
}
private static IBTCPayServerExtension GetExtensionInstanceFromType(Type type)
{
return (IBTCPayServerExtension)Activator.CreateInstance(type, Array.Empty<object>());
}
private static IFileProvider CreateEmbeddedFileProviderForAssembly(Assembly assembly)
{
return new EmbeddedFileProvider(assembly);
}
private static void ExecuteCommands(string extensionsFolder)
{
var pendingCommands = GetPendingCommands(extensionsFolder);
foreach (var command in pendingCommands)
{
ExecuteCommand(command, extensionsFolder);
}
File.Delete(Path.Combine(extensionsFolder, "commands"));
}
private static void ExecuteCommand((string command, string extension) command, string extensionsFolder)
{
var dirName = Path.Combine(extensionsFolder, command.extension);
switch (command.command)
{
case "delete":
if (Directory.Exists(dirName))
{
Directory.Delete(dirName, true);
}
break;
case "install":
var fileName = dirName + BTCPayExtensionSuffix;
if (File.Exists(fileName))
{
ZipFile.ExtractToDirectory(fileName, dirName, true);
File.Delete(fileName);
}
break;
}
}
public static (string command, string extension)[] GetPendingCommands(string extensionsFolder)
{
if (!File.Exists(Path.Combine(extensionsFolder, "commands")))
return Array.Empty<(string command, string extension)>();
var commands = File.ReadAllLines(Path.Combine(extensionsFolder, "commands"));
return commands.Select(s =>
{
var split = s.Split(':');
return (split[0].ToLower(CultureInfo.InvariantCulture), split[1]);
}).ToArray();
}
public static void QueueCommands(string extensionsFolder, params ( string action, string extension)[] commands)
{
File.AppendAllLines(Path.Combine(extensionsFolder, "commands"),
commands.Select((tuple) => $"{tuple.action}:{tuple.extension}"));
}
public static void CancelCommands(string extensionDir, string extension)
{
var cmds = GetPendingCommands(extensionDir).Where(tuple =>
!tuple.extension.Equals(extension, StringComparison.InvariantCultureIgnoreCase)).ToArray();
File.Delete(Path.Combine(extensionDir, "commands"));
QueueCommands(extensionDir, cmds);
}
}
}

View File

@ -0,0 +1,6 @@
@model IEnumerable<string>
@foreach (var partial in Model)
{
await Html.RenderPartialAsync(partial);
}

View File

@ -0,0 +1,23 @@
using System;
using System.Collections.Generic;
using System.Linq;
using BTCPayServer.Contracts;
using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.Components.UIExtensionPoint
{
public class UiExtensionPoint : ViewComponent
{
private readonly IEnumerable<IUIExtension> _uiExtensions;
public UiExtensionPoint(IEnumerable<IUIExtension> uiExtensions)
{
_uiExtensions = uiExtensions;
}
public IViewComponentResult Invoke(string location)
{
return View(_uiExtensions.Where(extension => extension.Location.Equals(location, StringComparison.InvariantCultureIgnoreCase)).Select(extension => extension.Partial));
}
}
}

View File

@ -80,7 +80,7 @@ namespace BTCPayServer.Configuration
{
NetworkType = DefaultConfiguration.GetNetworkType(conf);
DataDir = conf.GetDataDir(NetworkType);
ExtensionDir = conf.GetExtensionDir(NetworkType);
PluginDir = conf.GetPluginDir(NetworkType);
Logs.Configuration.LogInformation("Network: " + NetworkType.ToString());
if (conf.GetOrDefault<bool>("launchsettings", false) && NetworkType != NetworkType.Regtest)
@ -243,7 +243,7 @@ namespace BTCPayServer.Configuration
DisableRegistration = conf.GetOrDefault<bool>("disable-registration", true);
}
public string ExtensionDir { get; set; }
public string PluginDir { get; set; }
private SSHSettings ParseSSHConfiguration(IConfiguration conf)
{

View File

@ -68,10 +68,10 @@ namespace BTCPayServer.Configuration
return configuration.GetOrDefault("datadir", defaultSettings.DefaultDataDirectory);
}
public static string GetExtensionDir(this IConfiguration configuration, NetworkType networkType)
public static string GetPluginDir(this IConfiguration configuration, NetworkType networkType)
{
var defaultSettings = BTCPayDefaultSettings.GetDefaultSettings(networkType);
return configuration.GetOrDefault("extensiondir", defaultSettings.DefaultExtensionDirectory);
return configuration.GetOrDefault("plugindir", defaultSettings.DefaultPluginDirectory);
}
}
}

View File

@ -1,123 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Configuration;
using BTCPayServer.Contracts;
using BTCPayServer.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.Controllers
{
public partial class ServerController
{
[HttpGet("server/extensions")]
public async Task<IActionResult> ListExtensions(
[FromServices] ExtensionService extensionService,
[FromServices] BTCPayServerOptions btcPayServerOptions,
string remote = "btcpayserver/btcpayserver-extensions")
{
IEnumerable<ExtensionService.AvailableExtension> availableExtensions;
try
{
availableExtensions = await extensionService.GetRemoteExtensions(remote);
}
catch (Exception e)
{
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Error,
Message = "The remote could not be reached"
});
availableExtensions = Array.Empty<ExtensionService.AvailableExtension>();
}
var res = new ListExtensionsViewModel()
{
Installed = extensionService.LoadedExtensions,
Available = availableExtensions,
Remote = remote,
Commands = extensionService.GetPendingCommands(),
CanShowRestart = btcPayServerOptions.DockerDeployment
};
return View(res);
}
public class ListExtensionsViewModel
{
public string Remote { get; set; }
public IEnumerable<IBTCPayServerExtension> Installed { get; set; }
public IEnumerable<ExtensionService.AvailableExtension> Available { get; set; }
public (string command, string extension)[] Commands { get; set; }
public bool CanShowRestart { get; set; }
}
[HttpPost("server/extensions/uninstall")]
public IActionResult UnInstallExtension(
[FromServices] ExtensionService extensionService, string extension)
{
extensionService.UninstallExtension(extension);
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = "Extension scheduled to be uninstalled",
Severity = StatusMessageModel.StatusSeverity.Success
});
return RedirectToAction("ListExtensions");
}
[HttpPost("server/extensions/cancel")]
public IActionResult CancelExtensionCommands(
[FromServices] ExtensionService extensionService, string extension)
{
extensionService.CancelCommands(extension);
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = "Updated",
Severity = StatusMessageModel.StatusSeverity.Success
});
return RedirectToAction("ListExtensions");
}
[HttpPost("server/extensions/install")]
public async Task<IActionResult> InstallExtension(
[FromServices] ExtensionService extensionService, string remote, string extension)
{
try
{
await extensionService.DownloadRemoteExtension(remote, extension);
extensionService.InstallExtension(extension);
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = "Extension scheduled to be installed.",
Severity = StatusMessageModel.StatusSeverity.Success
});
}
catch (Exception e)
{
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = "The extension could not be downloaded. Try again later.", Severity = StatusMessageModel.StatusSeverity.Error
});
}
return RedirectToAction("ListExtensions");
}
[HttpPost("server/extensions/upload")]
public async Task<IActionResult> UploadExtension([FromServices] ExtensionService extensionService,
List<IFormFile> files)
{
foreach (var formFile in files.Where(file => file.Length > 0))
{
await extensionService.UploadExtension(formFile);
extensionService.InstallExtension(formFile.FileName.TrimEnd(ExtensionManager.BTCPayExtensionSuffix,
StringComparison.InvariantCultureIgnoreCase));
}
return RedirectToAction("ListExtensions",
new {StatusMessage = "Files uploaded, restart server to load extensions"});
}
}
}

View File

@ -0,0 +1,124 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Configuration;
using BTCPayServer.Contracts;
using BTCPayServer.Models;
using BTCPayServer.Plugins;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.Controllers
{
public partial class ServerController
{
[HttpGet("server/plugins")]
public async Task<IActionResult> ListPlugins(
[FromServices] PluginService pluginService,
[FromServices] BTCPayServerOptions btcPayServerOptions,
string remote = "btcpayserver/btcpayserver-plugins")
{
IEnumerable<PluginService.AvailablePlugin> availablePlugins;
try
{
availablePlugins = await pluginService.GetRemotePlugins(remote);
}
catch (Exception)
{
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Severity = StatusMessageModel.StatusSeverity.Error,
Message = "The remote could not be reached"
});
availablePlugins = Array.Empty<PluginService.AvailablePlugin>();
}
var res = new ListPluginsViewModel()
{
Installed = pluginService.LoadedPlugins,
Available = availablePlugins,
Remote = remote,
Commands = pluginService.GetPendingCommands(),
CanShowRestart = btcPayServerOptions.DockerDeployment
};
return View(res);
}
public class ListPluginsViewModel
{
public string Remote { get; set; }
public IEnumerable<IBTCPayServerPlugin> Installed { get; set; }
public IEnumerable<PluginService.AvailablePlugin> Available { get; set; }
public (string command, string plugin)[] Commands { get; set; }
public bool CanShowRestart { get; set; }
}
[HttpPost("server/plugins/uninstall")]
public IActionResult UnInstallPlugin(
[FromServices] PluginService pluginService, string plugin)
{
pluginService.UninstallPlugin(plugin);
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = "Plugin scheduled to be uninstalled",
Severity = StatusMessageModel.StatusSeverity.Success
});
return RedirectToAction("ListPlugins");
}
[HttpPost("server/plugins/cancel")]
public IActionResult CancelPluginCommands(
[FromServices] PluginService pluginService, string plugin)
{
pluginService.CancelCommands(plugin);
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = "Updated",
Severity = StatusMessageModel.StatusSeverity.Success
});
return RedirectToAction("ListPlugins");
}
[HttpPost("server/plugins/install")]
public async Task<IActionResult> InstallPlugin(
[FromServices] PluginService pluginService, string remote, string plugin)
{
try
{
await pluginService.DownloadRemotePlugin(remote, plugin);
pluginService.InstallPlugin(plugin);
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = "Plugin scheduled to be installed.",
Severity = StatusMessageModel.StatusSeverity.Success
});
}
catch (Exception)
{
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = "The plugin could not be downloaded. Try again later.", Severity = StatusMessageModel.StatusSeverity.Error
});
}
return RedirectToAction("ListPlugins");
}
[HttpPost("server/plugins/upload")]
public async Task<IActionResult> UploadPlugin([FromServices] PluginService pluginService,
List<IFormFile> files)
{
foreach (var formFile in files.Where(file => file.Length > 0))
{
await pluginService.UploadPlugin(formFile);
pluginService.InstallPlugin(formFile.FileName.TrimEnd(PluginManager.BTCPayPluginSuffix,
StringComparison.InvariantCultureIgnoreCase));
}
return RedirectToAction("ListPlugins",
new {StatusMessage = "Files uploaded, restart server to load plugins"});
}
}
}

View File

@ -224,15 +224,7 @@ namespace BTCPayServer
return false;
}
public static void SetStatusMessageModel(this ITempDataDictionary tempData, StatusMessageModel statusMessage)
{
if (statusMessage == null)
{
tempData.Remove("StatusMessageModel");
return;
}
tempData["StatusMessageModel"] = JObject.FromObject(statusMessage).ToString(Formatting.None);
}
public static StatusMessageModel GetStatusMessageModel(this ITempDataDictionary tempData)
{

View File

@ -12,6 +12,7 @@ using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.Payments.PayJoin;
using BTCPayServer.Plugins;
using BTCPayServer.Security;
using BTCPayServer.Security.Bitpay;
using BTCPayServer.Security.GreenField;
@ -83,6 +84,7 @@ namespace BTCPayServer.Hosting
services.AddEthereumLike();
#endif
services.TryAddSingleton<SettingsRepository>();
services.TryAddSingleton<ISettingsRepository>(provider => provider.GetService<SettingsRepository>());
services.TryAddSingleton<LabelFactory>();
services.TryAddSingleton<TorServices>();
services.TryAddSingleton<SocketFactory>();
@ -146,7 +148,7 @@ namespace BTCPayServer.Hosting
});
services.TryAddSingleton<AppService>();
services.AddSingleton<ExtensionService>();
services.AddSingleton<PluginService>();
services.TryAddTransient<Safe>();
services.TryAddSingleton<Ganss.XSS.HtmlSanitizer>(o =>
{

View File

@ -6,6 +6,7 @@ using BTCPayServer.Data;
using BTCPayServer.Filters;
using BTCPayServer.Logging;
using BTCPayServer.PaymentRequest;
using BTCPayServer.Plugins;
using BTCPayServer.Security;
using BTCPayServer.Services.Apps;
using BTCPayServer.Storage;
@ -91,7 +92,7 @@ namespace BTCPayServer.Hosting
#if RAZOR_RUNTIME_COMPILE
.AddRazorRuntimeCompilation()
#endif
.AddExtensions(services, Configuration, LoggerFactory)
.AddPlugins(services, Configuration, LoggerFactory)
.AddControllersAsServices();
@ -178,7 +179,7 @@ namespace BTCPayServer.Hosting
private static void ConfigureCore(IApplicationBuilder app, IWebHostEnvironment env, IServiceProvider prov, ILoggerFactory loggerFactory, BTCPayServerOptions options)
{
Logs.Configure(loggerFactory);
app.UseExtensions();
app.UsePlugins();
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();

View File

@ -0,0 +1,184 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Reflection;
using BTCPayServer.Configuration;
using BTCPayServer.Contracts;
using McMaster.NETCore.Plugins;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
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<Assembly> _pluginAssemblies = new List<Assembly>();
private static ILogger _logger;
public static IMvcBuilder AddPlugins(this IMvcBuilder mvcBuilder, IServiceCollection serviceCollection,
IConfiguration config, ILoggerFactory loggerFactory)
{
_logger = loggerFactory.CreateLogger(typeof(PluginManager));
var pluginsFolder = config.GetPluginDir(DefaultConfiguration.GetNetworkType(config));
var plugins = new List<IBTCPayServerPlugin>();
_logger.LogInformation($"Loading plugins from {pluginsFolder}");
Directory.CreateDirectory(pluginsFolder);
ExecuteCommands(pluginsFolder);
List<(PluginLoader, Assembly, IFileProvider)> loadedPlugins = new List<(PluginLoader, Assembly, IFileProvider)>();
var systemExtensions = GetDefaultLoadedPluginAssemblies();
plugins.AddRange(systemExtensions.SelectMany(assembly =>
GetAllPluginTypesFromAssembly(assembly).Select(GetPluginInstanceFromType)));
foreach (IBTCPayServerPlugin btcPayServerExtension in plugins)
{
btcPayServerExtension.SystemPlugin = true;
}
foreach (var dir in Directory.GetDirectories(pluginsFolder))
{
var pluginName = Path.GetFileName(dir);
var plugin = PluginLoader.CreateFromAssemblyFile(
Path.Combine(dir, pluginName + ".dll"), // 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);
mvcBuilder.AddPluginLoader(plugin);
var pluginAssembly = plugin.LoadDefaultAssembly();
_pluginAssemblies.Add(pluginAssembly);
var fileProvider = CreateEmbeddedFileProviderForAssembly(pluginAssembly);
loadedPlugins.Add((plugin, pluginAssembly, fileProvider));
plugins.AddRange(GetAllPluginTypesFromAssembly(pluginAssembly)
.Select(GetPluginInstanceFromType));
}
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<IBTCPayServerPlugin>())
{
extension.Execute(applicationBuilder,
applicationBuilder.ApplicationServices);
}
var webHostEnvironment = applicationBuilder.ApplicationServices.GetService<IWebHostEnvironment>();
List<IFileProvider> providers = new List<IFileProvider>() {webHostEnvironment.WebRootFileProvider};
providers.AddRange(
_pluginAssemblies
.Select(CreateEmbeddedFileProviderForAssembly));
webHostEnvironment.WebRootFileProvider = new CompositeFileProvider(providers);
}
private static Assembly[] GetDefaultLoadedPluginAssemblies()
{
return AppDomain.CurrentDomain.GetAssemblies().Where(assembly =>
assembly?.FullName?.StartsWith("BTCPayServer.Plugins",
StringComparison.InvariantCultureIgnoreCase) is true)
.ToArray();
}
private static Type[] GetAllPluginTypesFromAssembly(Assembly assembly)
{
return assembly.GetTypes().Where(type =>
typeof(IBTCPayServerPlugin).IsAssignableFrom(type) &&
!type.IsAbstract).ToArray();
}
private static IBTCPayServerPlugin GetPluginInstanceFromType(Type type)
{
return (IBTCPayServerPlugin)Activator.CreateInstance(type, Array.Empty<object>());
}
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)
{
var dirName = Path.Combine(pluginsFolder, command.extension);
switch (command.command)
{
case "delete":
if (Directory.Exists(dirName))
{
Directory.Delete(dirName, true);
}
break;
case "install":
var fileName = dirName + BTCPayPluginSuffix;
if (File.Exists(fileName))
{
ZipFile.ExtractToDirectory(fileName, dirName, true);
File.Delete(fileName);
}
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);
}
}
}

View File

@ -13,46 +13,46 @@ using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Newtonsoft.Json;
namespace BTCPayServer
namespace BTCPayServer.Plugins
{
public class ExtensionService
public class PluginService
{
private readonly BTCPayServerOptions _btcPayServerOptions;
private readonly HttpClient _githubClient;
public ExtensionService(IEnumerable<IBTCPayServerExtension> btcPayServerExtensions,
public PluginService(IEnumerable<IBTCPayServerPlugin> btcPayServerPlugins,
IHttpClientFactory httpClientFactory, BTCPayServerOptions btcPayServerOptions)
{
LoadedExtensions = btcPayServerExtensions;
LoadedPlugins = btcPayServerPlugins;
_githubClient = httpClientFactory.CreateClient();
_githubClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("btcpayserver", "1"));
_btcPayServerOptions = btcPayServerOptions;
}
public IEnumerable<IBTCPayServerExtension> LoadedExtensions { get; }
public IEnumerable<IBTCPayServerPlugin> LoadedPlugins { get; }
public async Task<IEnumerable<AvailableExtension>> GetRemoteExtensions(string remote)
public async Task<IEnumerable<AvailablePlugin>> GetRemotePlugins(string remote)
{
var resp = await _githubClient
.GetStringAsync(new Uri($"https://api.github.com/repos/{remote}/contents"));
var files = JsonConvert.DeserializeObject<GithubFile[]>(resp);
return await Task.WhenAll(files.Where(file => file.Name.EndsWith($"{ExtensionManager.BTCPayExtensionSuffix}.json", StringComparison.InvariantCulture)).Select(async file =>
return await Task.WhenAll(files.Where(file => file.Name.EndsWith($"{PluginManager.BTCPayPluginSuffix}.json", StringComparison.InvariantCulture)).Select(async file =>
{
return await _githubClient.GetStringAsync(file.DownloadUrl).ContinueWith(
task => JsonConvert.DeserializeObject<AvailableExtension>(task.Result), TaskScheduler.Current);
task => JsonConvert.DeserializeObject<AvailablePlugin>(task.Result), TaskScheduler.Current);
}));
}
public async Task DownloadRemoteExtension(string remote, string extension)
public async Task DownloadRemotePlugin(string remote, string plugin)
{
var dest = _btcPayServerOptions.ExtensionDir;
var dest = _btcPayServerOptions.PluginDir;
var resp = await _githubClient
.GetStringAsync(new Uri($"https://api.github.com/repos/{remote}/contents"));
var files = JsonConvert.DeserializeObject<GithubFile[]>(resp);
var ext = files.SingleOrDefault(file => file.Name == $"{extension}{ExtensionManager.BTCPayExtensionSuffix}");
var ext = files.SingleOrDefault(file => file.Name == $"{plugin}{PluginManager.BTCPayPluginSuffix}");
if (ext is null)
{
throw new Exception("Extension not found on remote");
throw new Exception("Plugin not found on remote");
}
var filedest = Path.Combine(dest, ext.Name);
@ -60,37 +60,40 @@ namespace BTCPayServer
new WebClient().DownloadFile(new Uri(ext.DownloadUrl), filedest);
}
public void InstallExtension(string extension)
public void InstallPlugin(string plugin)
{
var dest = _btcPayServerOptions.ExtensionDir;
UninstallExtension(extension);
ExtensionManager.QueueCommands(dest, ("install", extension));
var dest = _btcPayServerOptions.PluginDir;
UninstallPlugin(plugin);
PluginManager.QueueCommands(dest, ("install", plugin));
}
public async Task UploadExtension(IFormFile extension)
public async Task UploadPlugin(IFormFile plugin)
{
var dest = _btcPayServerOptions.ExtensionDir;
var filedest = Path.Combine(dest, extension.FileName);
var dest = _btcPayServerOptions.PluginDir;
var filedest = Path.Combine(dest, plugin.FileName);
Directory.CreateDirectory(Path.GetDirectoryName(filedest));
if (Path.GetExtension(filedest) == ExtensionManager.BTCPayExtensionSuffix)
if (Path.GetExtension(filedest) == PluginManager.BTCPayPluginSuffix)
{
await using var stream = new FileStream(filedest, FileMode.Create);
await extension.CopyToAsync(stream);
await plugin.CopyToAsync(stream);
}
}
public void UninstallExtension(string extension)
public void UninstallPlugin(string plugin)
{
var dest = _btcPayServerOptions.ExtensionDir;
ExtensionManager.QueueCommands(dest, ("delete", extension));
var dest = _btcPayServerOptions.PluginDir;
PluginManager.QueueCommands(dest, ("delete", plugin));
}
public class AvailableExtension : IBTCPayServerExtension
public class AvailablePlugin : IBTCPayServerPlugin
{
public string Identifier { get; set; }
public string Name { get; set; }
public Version Version { get; set; }
public string Description { get; set; }
public bool SystemPlugin { get; set; } = false;
public string[] Dependencies { get; } = Array.Empty<string>();
public void Execute(IApplicationBuilder applicationBuilder,
IServiceProvider applicationBuilderApplicationServices)
@ -111,14 +114,14 @@ namespace BTCPayServer
[JsonProperty("download_url")] public string DownloadUrl { get; set; }
}
public (string command, string extension)[] GetPendingCommands()
public (string command, string plugin)[] GetPendingCommands()
{
return ExtensionManager.GetPendingCommands(_btcPayServerOptions.ExtensionDir);
return PluginManager.GetPendingCommands(_btcPayServerOptions.PluginDir);
}
public void CancelCommands(string extension)
public void CancelCommands(string plugin)
{
ExtensionManager.CancelCommands(_btcPayServerOptions.ExtensionDir, extension);
PluginManager.CancelCommands(_btcPayServerOptions.PluginDir, plugin);
}
}
}

View File

@ -21,7 +21,8 @@ namespace BTCPayServer.Services.Altcoins.Ethereum
serviceCollection.AddSingleton<IHostedService, EthereumService>(provider => provider.GetService<EthereumService>());
serviceCollection.AddSingleton<EthereumLikePaymentMethodHandler>();
serviceCollection.AddSingleton<IPaymentMethodHandler>(provider => provider.GetService<EthereumLikePaymentMethodHandler>());
serviceCollection.AddSingleton<INavExtension, EthereumNavExtension>();
serviceCollection.AddSingleton<IUIExtension>(new UIExtension("Ethereum/StoreNavEthereumExtension", "store-nav"));
serviceCollection.AddTransient<NoRedirectHttpClientHandler>();
serviceCollection.AddSingleton<ISyncSummaryProvider, EthereumSyncSummaryProvider>();
serviceCollection.AddHttpClient(EthereumInvoiceCreateHttpClient)

View File

@ -1,12 +0,0 @@
#if ALTCOINS
using BTCPayServer.Contracts;
namespace BTCPayServer.Services.Altcoins.Ethereum
{
public class EthereumNavExtension: INavExtension
{
public string Partial { get; } = "Ethereum/StoreNavEthereumExtension";
public string Location { get; } = "store";
}
}
#endif

View File

@ -23,7 +23,7 @@ namespace BTCPayServer.Services.Altcoins.Monero
serviceCollection.AddHostedService<MoneroListener>();
serviceCollection.AddSingleton<MoneroLikePaymentMethodHandler>();
serviceCollection.AddSingleton<IPaymentMethodHandler>(provider => provider.GetService<MoneroLikePaymentMethodHandler>());
serviceCollection.AddSingleton<INavExtension, MoneroNavExtension>();
serviceCollection.AddSingleton<IUIExtension>(new UIExtension("Monero/StoreNavMoneroExtension", "store-nav"));
serviceCollection.AddSingleton<ISyncSummaryProvider, MoneroSyncSummaryProvider>();
return serviceCollection;

View File

@ -1,12 +0,0 @@
#if ALTCOINS
using BTCPayServer.Contracts;
namespace BTCPayServer.Services.Altcoins.Monero
{
public class MoneroNavExtension : INavExtension
{
public string Partial { get; } = "Monero/StoreNavMoneroExtension";
public string Location { get; } = "store";
}
}
#endif

View File

@ -7,7 +7,7 @@ using Newtonsoft.Json;
namespace BTCPayServer.Services
{
public class SettingsRepository
public class SettingsRepository : ISettingsRepository
{
private readonly ApplicationDbContextFactory _ContextFactory;
private readonly EventAggregator _EventAggregator;

View File

@ -7,5 +7,6 @@
<a id="@ManageNavPages.U2F.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.U2F)" asp-action="U2FAuthentication">U2F Authentication</a>
<a id="@ManageNavPages.APIKeys.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.APIKeys)" asp-action="APIKeys">API Keys</a>
<a id="@ManageNavPages.Notifications.ToString()" class="nav-link @ViewData.IsActivePage(ManageNavPages.Notifications)" asp-action="NotificationSettings">Notifications</a>
<vc:ui-extension-point location="user-nav" />
</div>

View File

@ -1,152 +0,0 @@
@model BTCPayServer.Controllers.ServerController.ListExtensionsViewModel
@{
ViewData.SetActivePageAndTitle(ServerNavPages.Extensions);
var installed = Model.Installed.Select(extension => extension.Identifier);
var availableAndNotInstalled = Model.Available.Where(extension => !installed.Contains(extension.Identifier));
}
@if (Model.Commands.Any())
{
<div class="alert alert-info">
You need to restart BTCPay Server in order to update your active extensions.
@if (Model.CanShowRestart)
{
<form method="post" asp-action="Maintenance">
<button type="submit" name="command" value="restart" class="btn btn-outline-info alert-link" asp-action="Maintenance">Restart now</button>
</form>
}
</div>
}
<partial name="_StatusMessage"/>
@if (Model.Installed.Any())
{
<h4>Installed Extensions</h4>
<div class="card-columns">
@foreach (var extension in Model.Installed)
{
var matchedAvailable = Model.Available.SingleOrDefault(availableExtension => availableExtension.Identifier == extension.Identifier);
<div class="card">
<div class="card-body">
<h3 class="card-title">
@extension.Name <span class="badge badge-secondary">@extension.Version</span>
</h3>
<p class="card-text">@extension.Description</p>
</div>
@if (matchedAvailable != null)
{
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between">
<span>Current version</span>
<span>@extension.Version</span>
</li>
<li class="list-group-item d-flex justify-content-between">
<span>Remote version</span>
<span>@matchedAvailable.Version</span>
</li>
</ul>
<div class="card-footer">
@if (Model.Commands.Any(tuple => tuple.extension.Equals(extension.Identifier, StringComparison.InvariantCultureIgnoreCase)))
{
<div class="d-flex justify-content-between">
<div>
<div class="badge badge-info">pending action</div></div>
<form asp-action="CancelExtensionCommands" asp-route-extension="@extension.Identifier">
<button type="submit" class="btn btn-link pt-0">Cancel</button>
</form>
</div>
}
else
{
<form asp-action="UnInstallExtension" asp-route-extension="@extension.Identifier">
<button type="submit" class="btn btn-link">Uninstall</button>
</form>
@if (extension.Version < matchedAvailable.Version)
{
<form asp-action="InstallExtension" asp-route-extension="@extension.Identifier" asp-route-remote="@Model.Remote">
<button type="submit" class="btn btn-link">Update</button>
</form>
}
}
</div>
}
</div>
}
</div>
}
@if (availableAndNotInstalled.Any())
{
<h4>Available Extensions</h4>
<div class="card-columns">
@foreach (var extension in availableAndNotInstalled)
{
<div class="card">
<div class="card-body">
<h3 class="card-title">
@extension.Name <span class="badge badge-secondary">@extension.Version</span>
</h3>
<p class="card-text">@extension.Description</p>
</div>
<div class="card-footer py-0 text-right">
@if (Model.Commands.Any(tuple => tuple.extension.Equals(extension.Identifier, StringComparison.InvariantCultureIgnoreCase)))
{
<div class="d-flex justify-content-between">
<div>
<div class="badge badge-info">pending action</div></div>
<form asp-action="CancelExtensionCommands" asp-route-extension="@extension.Identifier">
<button type="submit" class="btn btn-link pt-0">Cancel</button>
</form>
</div>
}
else
{
<form asp-action="InstallExtension" asp-route-extension="@extension.Identifier" asp-route-remote="@Model.Remote">
<button type="submit" class="btn btn-link">Install</button>
</form>
}
</div>
</div>
}
</div>
}
<button class="btn btn-link mt-4" type="button" data-toggle="collapse" data-target="#manual-upload">
Upload extension
</button>
<div class="collapse" id="manual-upload">
<div class="card">
<div class="card-body">
<h3 class="card-title">Add extension manually</h3>
<div class="alert alert-warning">This is an extremely dangerous operation. Do not upload extensions from someone that you do not trust.</div>
<form method="post" enctype="multipart/form-data" asp-action="UploadExtension">
<div class="form-group">
<input type="file" name="files" accept=".btcpay" id="files"/>
<button class="btn btn-primary" type="submit">Upload</button>
</div>
</form>
</div>
</div>
</div>
@section Scripts {
<script>
$(document).ready(function () {
$('.custom-file-input').on('change',
function () {
var label = $(this).next('label');
if (document.getElementById("file").files.length > 0) {
var fileName = document.getElementById("file").files[0].name;
label.addClass("selected").html(fileName);
} else {
label.removeClass("selected").html("Choose file");
}
});
});
</script>
}

View File

@ -0,0 +1,199 @@
@model BTCPayServer.Controllers.ServerController.ListPluginsViewModel
@{
ViewData.SetActivePageAndTitle(ServerNavPages.Plugins);
var installed = Model.Installed.Select(plugin => plugin.Identifier);
var availableAndNotInstalled = Model.Available.Where(plugin => !installed.Contains(plugin.Identifier));
}
@if (Model.Commands.Any())
{
<div class="alert alert-info">
You need to restart BTCPay Server in order to update your active plugins.
@if (Model.CanShowRestart)
{
<form method="post" asp-action="Maintenance">
<button type="submit" name="command" value="restart" class="btn btn-outline-info alert-link" asp-action="Maintenance">Restart now</button>
</form>
}
</div>
}
<partial name="_StatusMessage"/>
@if (Model.Installed.Any())
{
<h4>Installed Plugins</h4>
<div class="card-columns">
@foreach (var plugin in Model.Installed)
{
var matchedAvailable = Model.Available.SingleOrDefault(availablePlugin => availablePlugin.Identifier == plugin.Identifier);
<div class="card">
<div class="card-body">
<h3 class="card-title">
@plugin.Name <span class="badge badge-secondary">@plugin.Version</span>
</h3>
<p class="card-text">@plugin.Description</p>
</div>
@if (!plugin.SystemPlugin)
{
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between">
<span>Current version</span>
<span>@plugin.Version</span>
</li>
@if (matchedAvailable != null)
{
<li class="list-group-item d-flex justify-content-between">
<span>Remote version</span>
<span>@matchedAvailable.Version</span>
</li>
}
</ul>
<div class="card-footer">
@if (Model.Commands.Any(tuple => tuple.plugin.Equals(plugin.Identifier, StringComparison.InvariantCultureIgnoreCase)))
{
<div class="d-flex justify-content-between">
<div>
<div class="badge badge-info">pending action</div>
</div>
<form asp-action="CancelPluginCommands" asp-route-plugin="@plugin.Identifier">
<button type="submit" class="btn btn-link pt-0">Cancel</button>
</form>
</div>
}
else
{
<form asp-action="UnInstallPlugin" asp-route-plugin="@plugin.Identifier">
<button type="submit" class="btn btn-link pt-0">Uninstall</button>
</form>
@if (matchedAvailable != null && plugin.Version < matchedAvailable.Version)
{
<form asp-action="InstallPlugin" asp-route-plugin="@plugin.Identifier" asp-route-remote="@Model.Remote">
<button type="submit" class="btn btn-link pt-0">Update</button>
</form>
}
}
</div>
}
else if (plugin.SystemPlugin)
{
<div class="card-footer">
<div class="d-flex justify-content-between">
<div>
<div class="badge badge-info">system plugin</div>
</div>
</div>
</div>
}
</div>
}
</div>
}
@if (availableAndNotInstalled.Any())
{
<h4>Available Plugins</h4>
<div class="card-columns">
@foreach (var plugin in availableAndNotInstalled)
{
<div class="card">
<div class="card-body">
<h3 class="card-title">
@plugin.Name <span class="badge badge-secondary">@plugin.Version</span>
</h3>
<p class="card-text">@plugin.Description</p>
</div>
<div class="card-footer">
@if (Model.Commands.Any(tuple => tuple.plugin.Equals(plugin.Identifier, StringComparison.InvariantCultureIgnoreCase)))
{
<div class="d-flex justify-content-between">
<div>
<div class="badge badge-info">pending action</div>
</div>
<form asp-action="CancelPluginCommands" asp-route-plugin="@plugin.Identifier">
<button type="submit" class="btn btn-link pt-0">Cancel</button>
</form>
</div>
}
else
{
<form asp-action="InstallPlugin" asp-route-plugin="@plugin.Identifier" asp-route-remote="@Model.Remote">
<button type="submit" class="btn btn-link pt-0">Install</button>
</form>
}
</div>
</div>
}
</div>
}
<button class="btn btn-link mt-4" type="button" data-toggle="collapse" data-target="#manual-upload">
Upload plugin
</button>
<div class="collapse" id="manual-upload">
<div class="card">
<div class="card-body">
<h3 class="card-title">Add plugin manually</h3>
<div class="alert alert-warning">This is an extremely dangerous operation. Do not upload plugins from someone that you do not trust.</div>
<form method="post" enctype="multipart/form-data" asp-action="UploadPlugin">
<div class="form-group">
<div class="custom-file">
<input type="file" class="custom-file-input" required name="files" accept=".btcpay" id="files">
<label class="custom-file-label" for="files">Choose file</label>
</div>
</div>
<div class="form-group">
<button class="btn btn-primary" type="submit">Upload</button>
</div>
</form>
</div>
</div>
</div>
@if (Model.Commands.Any())
{
<button class="btn btn-link mt-4" type="button" data-toggle="collapse" data-target="#pending-actions">
Pending
</button>
<div class="collapse" id="pending-actions">
<div class="card">
<div class="card-body">
<h3 class="card-title">Pending actions</h3>
<ul class="list-group list-group-flush">
@foreach (var extComm in Model.Commands.GroupBy(tuple => tuple.plugin))
{
<li class="list-group-item">
<div class="d-flex justify-content-between">
@extComm.Key (@extComm.Last().command)
<form asp-action="CancelPluginCommands" asp-route-plugin="@extComm.Key">
<button type="submit" class="btn btn-link pt-0">Cancel</button>
</form>
</div>
</li>
}
</ul>
</div>
</div>
</div>
}
@section Scripts {
<script>
$(document).ready(function () {
$('.custom-file-input').on('change',
function () {
var label = $(this).next('label');
var el = $(this).get(0);
if (el.files.length > 0) {
var fileName = el.files[0].name;
label.addClass("selected").html(fileName);
} else {
label.removeClass("selected").html("Choose file");
}
});
});
</script>
}

View File

@ -2,6 +2,6 @@ namespace BTCPayServer.Views.Server
{
public enum ServerNavPages
{
Index, Users, Emails, Policies, Theme, Services, Maintenance, Logs, Files, Extensions
Index, Users, Emails, Policies, Theme, Services, Maintenance, Logs, Files, Plugins
}
}

View File

@ -12,5 +12,6 @@
}
<a asp-controller="Server" id="Server-@ServerNavPages.Logs" class="nav-link @ViewData.IsActivePage(ServerNavPages.Logs)" asp-action="LogsView">Logs</a>
<a asp-controller="Server" id="Server-@ServerNavPages.Files" class="nav-link @ViewData.IsActivePage(ServerNavPages.Files)" asp-action="Files">Files</a>
<a asp-controller="Server" id="Server-@ServerNavPages.Extensions" class="nav-link @ViewData.IsActivePage(ServerNavPages.Extensions)" asp-action="ListExtensions">Extensions (experimental)</a>
<a asp-controller="Server" id="Server-@ServerNavPages.Plugins" class="nav-link @ViewData.IsActivePage(ServerNavPages.Plugins)" asp-action="ListPlugins">Plugins (experimental)</a>
<vc:ui-extension-point location="server-nav" />
</div>

View File

@ -40,12 +40,7 @@
</button>
<div class="collapse navbar-collapse" id="navbarResponsive">
<ul class="navbar-nav ml-auto">
@inject IEnumerable<BTCPayServer.Contracts.INavExtension> Extensions;
@foreach (var extension in Extensions.Where(extension => extension.Location == "header-nav"))
{
<partial name="@extension.Partial"/>
}
<vc:ui-extension-point location="header-nav" />
@if (SignInManager.IsSignedIn(User))
{
if (User.IsInRole(Roles.ServerAdmin))

View File

@ -6,11 +6,7 @@
<a id="@(nameof(StoreNavPages.Users))" class="nav-link @ViewData.IsActivePage(StoreNavPages.Users)" asp-controller="Stores" asp-action="StoreUsers" asp-route-storeId="@this.Context.GetRouteValue("storeId")">Users</a>
<a id="@(nameof(StoreNavPages.PayButton))" class="nav-link @ViewData.IsActivePage(StoreNavPages.PayButton)" asp-controller="Stores" asp-action="PayButton" asp-route-storeId="@this.Context.GetRouteValue("storeId")">Pay Button</a>
<a id="@(nameof(StoreNavPages.Integrations))" class="nav-link @ViewData.IsActivePage(StoreNavPages.Integrations)" asp-controller="Stores" asp-action="Integrations" asp-route-storeId="@this.Context.GetRouteValue("storeId")">Integrations</a>
@inject IEnumerable<BTCPayServer.Contracts.INavExtension> Extensions;
@foreach (var extension in Extensions.Where(extension => extension.Location == "store"))
{
<partial name="@extension.Partial" />
}
<vc:ui-extension-point location="store-nav" />
</div>
<script type="text/javascript">

View File

@ -18,6 +18,10 @@ namespace BTCPayServer.Views
public static string IsActivePage<T>(this ViewDataDictionary viewData, T page)
where T : IConvertible
{
if (!viewData.ContainsKey(ACTIVE_PAGE_KEY))
{
return null;
}
var activePage = (T)viewData[ACTIVE_PAGE_KEY];
return page.Equals(activePage) ? "active" : null;
}

View File

@ -19,4 +19,5 @@
<a class="nav-link @ViewData.IsActivePage(WalletsNavPages.PSBT)" asp-action="WalletPSBT" asp-route-walletId="@this.Context.GetRouteValue("walletId")">PSBT</a>
<a class="nav-link @ViewData.IsActivePage(WalletsNavPages.Settings)" asp-action="WalletSettings" asp-route-walletId="@this.Context.GetRouteValue("walletId")" id="WalletSettings">Settings</a>
}
<vc:ui-extension-point location="wallet-nav" />
</div>

View File

@ -30,7 +30,11 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BTCPayServer.Client", "BTCP
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Abstractions", "BTCPayServer.Abstractions\BTCPayServer.Abstractions.csproj", "{A0D50BB6-FE2C-4671-8693-F7582B66178F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Test", "BTCPayServer.Test\BTCPayServer.Test.csproj", "{545AFC8E-7BC2-43D9-84CA-F9468F4FF295}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Plugins.Test", "BTCPayServer.Plugins.Test\BTCPayServer.Plugins.Test.csproj", "{545AFC8E-7BC2-43D9-84CA-F9468F4FF295}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Plugins", "Plugins", "{1FC7F660-ADF1-4D55-B61A-85C6AB083C33}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.PluginPacker", "BTCPayServer.PluginPacker\BTCPayServer.PluginPacker.csproj", "{7DC94B25-1CFC-4170-AA41-7BA983E4C0B8}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@ -240,6 +244,30 @@ Global
{545AFC8E-7BC2-43D9-84CA-F9468F4FF295}.Release|x64.Build.0 = Release|Any CPU
{545AFC8E-7BC2-43D9-84CA-F9468F4FF295}.Release|x86.ActiveCfg = Release|Any CPU
{545AFC8E-7BC2-43D9-84CA-F9468F4FF295}.Release|x86.Build.0 = Release|Any CPU
{7DC94B25-1CFC-4170-AA41-7BA983E4C0B8}.Altcoins-Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7DC94B25-1CFC-4170-AA41-7BA983E4C0B8}.Altcoins-Debug|Any CPU.Build.0 = Debug|Any CPU
{7DC94B25-1CFC-4170-AA41-7BA983E4C0B8}.Altcoins-Debug|x64.ActiveCfg = Debug|Any CPU
{7DC94B25-1CFC-4170-AA41-7BA983E4C0B8}.Altcoins-Debug|x64.Build.0 = Debug|Any CPU
{7DC94B25-1CFC-4170-AA41-7BA983E4C0B8}.Altcoins-Debug|x86.ActiveCfg = Debug|Any CPU
{7DC94B25-1CFC-4170-AA41-7BA983E4C0B8}.Altcoins-Debug|x86.Build.0 = Debug|Any CPU
{7DC94B25-1CFC-4170-AA41-7BA983E4C0B8}.Altcoins-Release|Any CPU.ActiveCfg = Debug|Any CPU
{7DC94B25-1CFC-4170-AA41-7BA983E4C0B8}.Altcoins-Release|Any CPU.Build.0 = Debug|Any CPU
{7DC94B25-1CFC-4170-AA41-7BA983E4C0B8}.Altcoins-Release|x64.ActiveCfg = Debug|Any CPU
{7DC94B25-1CFC-4170-AA41-7BA983E4C0B8}.Altcoins-Release|x64.Build.0 = Debug|Any CPU
{7DC94B25-1CFC-4170-AA41-7BA983E4C0B8}.Altcoins-Release|x86.ActiveCfg = Debug|Any CPU
{7DC94B25-1CFC-4170-AA41-7BA983E4C0B8}.Altcoins-Release|x86.Build.0 = Debug|Any CPU
{7DC94B25-1CFC-4170-AA41-7BA983E4C0B8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7DC94B25-1CFC-4170-AA41-7BA983E4C0B8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7DC94B25-1CFC-4170-AA41-7BA983E4C0B8}.Debug|x64.ActiveCfg = Debug|Any CPU
{7DC94B25-1CFC-4170-AA41-7BA983E4C0B8}.Debug|x64.Build.0 = Debug|Any CPU
{7DC94B25-1CFC-4170-AA41-7BA983E4C0B8}.Debug|x86.ActiveCfg = Debug|Any CPU
{7DC94B25-1CFC-4170-AA41-7BA983E4C0B8}.Debug|x86.Build.0 = Debug|Any CPU
{7DC94B25-1CFC-4170-AA41-7BA983E4C0B8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7DC94B25-1CFC-4170-AA41-7BA983E4C0B8}.Release|Any CPU.Build.0 = Release|Any CPU
{7DC94B25-1CFC-4170-AA41-7BA983E4C0B8}.Release|x64.ActiveCfg = Release|Any CPU
{7DC94B25-1CFC-4170-AA41-7BA983E4C0B8}.Release|x64.Build.0 = Release|Any CPU
{7DC94B25-1CFC-4170-AA41-7BA983E4C0B8}.Release|x86.ActiveCfg = Release|Any CPU
{7DC94B25-1CFC-4170-AA41-7BA983E4C0B8}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@ -247,4 +275,8 @@ Global
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {203A3162-BE45-4721-937D-6804E0E1AFF8}
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{545AFC8E-7BC2-43D9-84CA-F9468F4FF295} = {1FC7F660-ADF1-4D55-B61A-85C6AB083C33}
{7DC94B25-1CFC-4170-AA41-7BA983E4C0B8} = {1FC7F660-ADF1-4D55-B61A-85C6AB083C33}
EndGlobalSection
EndGlobal