BTCPay Server Extensions (#1925)

* BTCPay Server Extensions

![demo](https://i.imgur.com/2S00aL2.gif)

* cleanup

* fix

* Polish UI a bit,detect when docker deployment
This commit is contained in:
Andrew Camilleri 2020-10-15 14:28:09 +02:00 committed by GitHub
parent 51a072808f
commit 1440e8c55d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 884 additions and 37 deletions

View File

@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="../Build/Version.csproj" Condition="Exists('../Build/Version.csproj')" />
<Import Project="../Build/Common.csproj" />
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup Condition="'$(Altcoins)' != 'true'">
<Compile Remove="Altcoins\**\*.cs"></Compile>
</ItemGroup>
</Project>

View File

@ -0,0 +1,17 @@
using System;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
namespace BTCPayServer.Contracts
{
public interface IBTCPayServerExtension
{
public string Identifier { get;}
string Name { get; }
Version Version { get; }
string Description { get; }
void Execute(IApplicationBuilder applicationBuilder, IServiceProvider applicationBuilderApplicationServices);
void Execute(IServiceCollection applicationBuilder);
}
}

View File

@ -0,0 +1,20 @@
using System;
namespace BTCPayServer.Contracts
{
public interface INotificationHandler
{
string NotificationType { get; }
Type NotificationBlobType { get; }
void FillViewModel(object notification, NotificationViewModel vm);
}
public class NotificationViewModel
{
public string Id { get; set; }
public DateTimeOffset Created { get; set; }
public string Body { get; set; }
public string ActionLink { get; set; }
public bool Seen { get; set; }
}
}

View File

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

View File

@ -6,5 +6,4 @@ namespace BTCPayServer.Contracts
string Partial { get; }
}
}

View File

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

View File

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<AddRazorSupportForMvc>true</AddRazorSupportForMvc>
<PreserveCompilationContext>false</PreserveCompilationContext>
<GenerateEmbeddedFilesManifest>true</GenerateEmbeddedFilesManifest>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<ProjectReference Include="..\BTCPayServer.Abstractions\BTCPayServer.Abstractions.csproj" />
<EmbeddedResource Include="Resources\**" />
</ItemGroup>
</Project>

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@ -0,0 +1,25 @@
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

@ -0,0 +1,16 @@
using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.Test
{
[Route("extensions/test")]
public class TestExtensionController : Controller
{
// GET
public IActionResult Index()
{
return View();
}
}
}

View File

@ -0,0 +1,2 @@
<li class="nav-item"><a asp-controller="TestExtension" asp-action="Index" class="nav-link js-scroll-trigger" >Dear Nicolas Dorier</a></li>

View File

@ -0,0 +1,9 @@
<section>
<div class="container">
<h1>Challenge Completed!!</h1>
Here is also an image loaded from the plugin<br/>
<a href="https://twitter.com/NicolasDorier/status/1307221679014256640">
<img src="/Resources/img/screengrab.png"/>
</a>
</div>
</section>

View File

@ -0,0 +1 @@
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

44
BTCPayServer.Test/ss.cs Normal file
View File

@ -0,0 +1,44 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.ApplicationParts;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace BTCPayServer.Test
{
public class ApplicationPartsLogger : IHostedService
{
private readonly ILogger<ApplicationPartsLogger> _logger;
private readonly ApplicationPartManager _partManager;
public ApplicationPartsLogger(ILogger<ApplicationPartsLogger> logger, ApplicationPartManager partManager)
{
_logger = logger;
_partManager = partManager;
}
public Task StartAsync(CancellationToken cancellationToken)
{
// Get the names of all the application parts. This is the short assembly name for AssemblyParts
var applicationParts = _partManager.ApplicationParts.Select(x => x.Name);
// Create a controller feature, and populate it from the application parts
var controllerFeature = new ControllerFeature();
_partManager.PopulateFeature(controllerFeature);
// Get the names of all of the controllers
var controllers = controllerFeature.Controllers.Select(x => x.Name);
// Log the application parts and controllers
_logger.LogInformation("Found the following application parts: '{ApplicationParts}' with the following controllers: '{Controllers}'",
string.Join(", ", applicationParts), string.Join(", ", controllers));
return Task.CompletedTask;
}
// Required by the interface
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
}

View File

@ -0,0 +1,160 @@
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,124 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using BTCPayServer.Configuration;
using BTCPayServer.Contracts;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Newtonsoft.Json;
namespace BTCPayServer
{
public class ExtensionService
{
private readonly BTCPayServerOptions _btcPayServerOptions;
private readonly HttpClient _githubClient;
public ExtensionService(IEnumerable<IBTCPayServerExtension> btcPayServerExtensions,
IHttpClientFactory httpClientFactory, BTCPayServerOptions btcPayServerOptions)
{
LoadedExtensions = btcPayServerExtensions;
_githubClient = httpClientFactory.CreateClient();
_githubClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("btcpayserver", "1"));
_btcPayServerOptions = btcPayServerOptions;
}
public IEnumerable<IBTCPayServerExtension> LoadedExtensions { get; }
public async Task<IEnumerable<AvailableExtension>> GetRemoteExtensions(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 _githubClient.GetStringAsync(file.DownloadUrl).ContinueWith(
task => JsonConvert.DeserializeObject<AvailableExtension>(task.Result), TaskScheduler.Current);
}));
}
public async Task DownloadRemoteExtension(string remote, string extension)
{
var dest = _btcPayServerOptions.ExtensionDir;
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}");
if (ext is null)
{
throw new Exception("Extension not found on remote");
}
var filedest = Path.Combine(dest, ext.Name);
Directory.CreateDirectory(Path.GetDirectoryName(filedest));
new WebClient().DownloadFile(new Uri(ext.DownloadUrl), filedest);
}
public void InstallExtension(string extension)
{
var dest = _btcPayServerOptions.ExtensionDir;
UninstallExtension(extension);
ExtensionManager.QueueCommands(dest, ("install", extension));
}
public async Task UploadExtension(IFormFile extension)
{
var dest = _btcPayServerOptions.ExtensionDir;
var filedest = Path.Combine(dest, extension.FileName);
Directory.CreateDirectory(Path.GetDirectoryName(filedest));
if (Path.GetExtension(filedest) == ExtensionManager.BTCPayExtensionSuffix)
{
await using var stream = new FileStream(filedest, FileMode.Create);
await extension.CopyToAsync(stream);
}
}
public void UninstallExtension(string extension)
{
var dest = _btcPayServerOptions.ExtensionDir;
ExtensionManager.QueueCommands(dest, ("delete", extension));
}
public class AvailableExtension : IBTCPayServerExtension
{
public string Identifier { get; set; }
public string Name { get; set; }
public Version Version { get; set; }
public string Description { get; set; }
public void Execute(IApplicationBuilder applicationBuilder,
IServiceProvider applicationBuilderApplicationServices)
{
}
public void Execute(IServiceCollection applicationBuilder)
{
}
}
class GithubFile
{
[JsonProperty("name")] public string Name { get; set; }
[JsonProperty("sha")] public string Sha { get; set; }
[JsonProperty("download_url")] public string DownloadUrl { get; set; }
}
public (string command, string extension)[] GetPendingCommands()
{
return ExtensionManager.GetPendingCommands(_btcPayServerOptions.ExtensionDir);
}
public void CancelCommands(string extension)
{
ExtensionManager.CancelCommands(_btcPayServerOptions.ExtensionDir, extension);
}
}
}

View File

@ -4,8 +4,11 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<PreserveCompilationContext>true</PreserveCompilationContext>
</PropertyGroup>
<ItemGroup>
<Compile Remove="Build\**" />
<Compile Remove="wwwroot\bundles\jqueryvalidate\**" />
<Compile Remove="wwwroot\vendor\jquery-nice-select\**" />
@ -52,6 +55,7 @@
<PackageReference Include="BundlerMinifier.TagHelpers" Version="3.2.435" />
<PackageReference Include="CsvHelper" Version="15.0.5" />
<PackageReference Include="HtmlSanitizer" Version="4.0.217" />
<PackageReference Include="McMaster.NETCore.Plugins.Mvc" Version="1.3.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Filter" Version="1.1.2" />
<PackageReference Include="Microsoft.NetCore.Analyzers" Version="2.9.8">
<PrivateAssets>all</PrivateAssets>
@ -141,6 +145,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\BTCPayServer.Abstractions\BTCPayServer.Abstractions.csproj" />
<ProjectReference Include="..\BTCPayServer.Client\BTCPayServer.Client.csproj" />
<ProjectReference Include="..\BTCPayServer.Data\BTCPayServer.Data.csproj" />
<ProjectReference Include="..\BTCPayServer.Rating\BTCPayServer.Rating.csproj" />

View File

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Contracts;
using BTCPayServer.Models.NotificationViewModels;
namespace BTCPayServer.Components.NotificationsDropdown

View File

@ -80,6 +80,7 @@ namespace BTCPayServer.Configuration
{
NetworkType = DefaultConfiguration.GetNetworkType(conf);
DataDir = conf.GetDataDir(NetworkType);
ExtensionDir = conf.GetExtensionDir(NetworkType);
Logs.Configuration.LogInformation("Network: " + NetworkType.ToString());
if (conf.GetOrDefault<bool>("launchsettings", false) && NetworkType != NetworkType.Regtest)
@ -166,6 +167,7 @@ namespace BTCPayServer.Configuration
PostgresConnectionString = conf.GetOrDefault<string>("postgres", null);
MySQLConnectionString = conf.GetOrDefault<string>("mysql", null);
BundleJsCss = conf.GetOrDefault<bool>("bundlejscss", true);
DockerDeployment = conf.GetOrDefault<bool>("dockerdeployment", true);
AllowAdminRegistration = conf.GetOrDefault<bool>("allow-admin-registration", false);
TorrcFile = conf.GetOrDefault<string>("torrcfile", null);
@ -239,6 +241,8 @@ namespace BTCPayServer.Configuration
DisableRegistration = conf.GetOrDefault<bool>("disable-registration", true);
}
public string ExtensionDir { get; set; }
private SSHSettings ParseSSHConfiguration(IConfiguration conf)
{
var settings = new SSHSettings();
@ -281,6 +285,7 @@ namespace BTCPayServer.Configuration
public ExternalServices ExternalServices { get; set; } = new ExternalServices();
public BTCPayNetworkProvider NetworkProvider { get; set; }
public bool DockerDeployment { get; set; }
public string PostgresConnectionString
{
get;

View File

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

View File

@ -1,7 +0,0 @@
namespace BTCPayServer.Contracts
{
public interface IStoreNavExtension
{
string Partial { get; }
}
}

View File

@ -0,0 +1,109 @@
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 = "kukks/btcpayserver-extensions")
{
var res = new ListExtensionsViewModel()
{
Installed = extensionService.LoadedExtensions,
Available = await extensionService.GetRemoteExtensions(remote),
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 = e.Message, 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

@ -105,6 +105,12 @@ namespace BTCPayServer.Controllers
public async Task<IActionResult> Maintenance(MaintenanceViewModel vm, string command)
{
vm.CanUseSSH = _sshState.CanUseSSH;
if (!vm.CanUseSSH)
{
TempData[WellKnownTempData.ErrorMessage] = "Maintenance feature requires access to SSH properly configured in BTCPayServer configuration.";
return View(vm);
}
if (!ModelState.IsValid)
return View(vm);
if (command == "changedomain")
@ -182,6 +188,13 @@ namespace BTCPayServer.Controllers
return error;
TempData[WellKnownTempData.SuccessMessage] = $"The old docker images will be cleaned soon...";
}
else if (command == "restart")
{
var error = await RunSSH(vm, $"btcpay-restart.sh");
if (error != null)
return error;
TempData[WellKnownTempData.SuccessMessage] = $"BTCPay will restart momentarily.";
}
else
{
return NotFound();

View File

@ -0,0 +1,18 @@
using System;
namespace BTCPayServer
{
public static class StringExtensions
{
public static string TrimEnd(this string input, string suffixToRemove,
StringComparison comparisonType)
{
if (input != null && suffixToRemove != null
&& input.EndsWith(suffixToRemove, comparisonType))
{
return input.Substring(0, input.Length - suffixToRemove.Length);
}
else return input;
}
}
}

View File

@ -139,6 +139,7 @@ namespace BTCPayServer.Hosting
});
services.TryAddSingleton<AppService>();
services.AddSingleton<ExtensionService>();
services.TryAddTransient<Safe>();
services.TryAddSingleton<Ganss.XSS.HtmlSanitizer>(o =>
{

View File

@ -56,7 +56,7 @@ namespace BTCPayServer.Hosting
services.AddProviderStorage();
services.AddSession();
services.AddSignalR();
services.AddMvc(o =>
var mvcBuilder= services.AddMvc(o =>
{
o.Filters.Add(new XFrameOptionsAttribute("DENY"));
o.Filters.Add(new XContentTypeOptionsAttribute("nosniff"));
@ -91,7 +91,11 @@ namespace BTCPayServer.Hosting
#if RAZOR_RUNTIME_COMPILE
.AddRazorRuntimeCompilation()
#endif
.AddExtensions(services, Configuration, LoggerFactory)
.AddControllersAsServices();
services.TryAddScoped<ContentSecurityPolicies>();
services.Configure<IdentityOptions>(options =>
{
@ -174,6 +178,7 @@ namespace BTCPayServer.Hosting
private static void ConfigureCore(IApplicationBuilder app, IWebHostEnvironment env, IServiceProvider prov, ILoggerFactory loggerFactory, BTCPayServerOptions options)
{
Logs.Configure(loggerFactory);
app.UseExtensions();
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();

View File

@ -1,5 +1,5 @@
using System;
using System.Collections.Generic;
using BTCPayServer.Contracts;
namespace BTCPayServer.Models.NotificationViewModels
{
@ -11,12 +11,5 @@ namespace BTCPayServer.Models.NotificationViewModels
public List<NotificationViewModel> Items { get; set; }
}
public class NotificationViewModel
{
public string Id { get; set; }
public DateTimeOffset Created { get; set; }
public string Body { get; set; }
public string ActionLink { get; set; }
public bool Seen { get; set; }
}
}

View File

@ -20,7 +20,8 @@
"BTCPAY_DEBUGLOG": "debug.log",
"BTCPAY_TORRCFILE": "../BTCPayServer.Tests/TestData/Tor/torrc",
"BTCPAY_SOCKSENDPOINT": "localhost:9050",
"BTCPAY_UPDATEURL": ""
"BTCPAY_UPDATEURL": "",
"BTCPAY_DOCKERDEPLOYMENT": "true"
},
"applicationUrl": "http://127.0.0.1:14142/"
},
@ -51,7 +52,8 @@
"BTCPAY_SSHPASSWORD": "opD3i2282D",
"BTCPAY_DEBUGLOG": "debug.log",
"BTCPAY_TORRCFILE": "../BTCPayServer.Tests/TestData/Tor/torrc",
"BTCPAY_SOCKSENDPOINT": "localhost:9050"
"BTCPAY_SOCKSENDPOINT": "localhost:9050",
"BTCPAY_DOCKERDEPLOYMENT": "true"
},
"applicationUrl": "https://localhost:14142/"
},
@ -84,7 +86,8 @@
"BTCPAY_SSHPASSWORD": "opD3i2282D",
"BTCPAY_DEBUGLOG": "debug.log",
"BTCPAY_TORRCFILE": "../BTCPayServer.Tests/TestData/Tor/torrc",
"BTCPAY_SOCKSENDPOINT": "localhost:9050"
"BTCPAY_SOCKSENDPOINT": "localhost:9050",
"BTCPAY_DOCKERDEPLOYMENT": "true"
},
"applicationUrl": "https://localhost:14142/"
}

View File

@ -21,7 +21,7 @@ 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<IStoreNavExtension, EthereumStoreNavExtension>();
serviceCollection.AddSingleton<INavExtension, EthereumNavExtension>();
serviceCollection.AddTransient<NoRedirectHttpClientHandler>();
serviceCollection.AddSingleton<ISyncSummaryProvider, EthereumSyncSummaryProvider>();
serviceCollection.AddHttpClient(EthereumInvoiceCreateHttpClient)

View File

@ -3,9 +3,10 @@ using BTCPayServer.Contracts;
namespace BTCPayServer.Services.Altcoins.Ethereum
{
public class EthereumStoreNavExtension: IStoreNavExtension
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<IStoreNavExtension, MoneroStoreNavExtension>();
serviceCollection.AddSingleton<INavExtension, MoneroNavExtension>();
serviceCollection.AddSingleton<ISyncSummaryProvider, MoneroSyncSummaryProvider>();
return serviceCollection;

View File

@ -3,9 +3,10 @@ using BTCPayServer.Contracts;
namespace BTCPayServer.Services.Altcoins.Monero
{
public class MoneroStoreNavExtension : IStoreNavExtension
public class MoneroNavExtension : INavExtension
{
public string Partial { get; } = "Monero/StoreNavMoneroExtension";
public string Location { get; } = "store";
}
}
#endif

View File

@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Linq;
using BTCPayServer.Configuration;
using BTCPayServer.Contracts;
using BTCPayServer.Controllers;
using BTCPayServer.Events;
using BTCPayServer.Models.NotificationViewModels;

View File

@ -1,5 +1,5 @@
#if DEBUG
using BTCPayServer.Models.NotificationViewModels;
using BTCPayServer.Contracts;
namespace BTCPayServer.Services.Notifications.Blobs
{

View File

@ -1,3 +1,4 @@
using BTCPayServer.Contracts;
using BTCPayServer.Models.NotificationViewModels;
namespace BTCPayServer.Services.Notifications.Blobs

View File

@ -1,4 +1,5 @@
using BTCPayServer.Configuration;
using BTCPayServer.Contracts;
using BTCPayServer.Controllers;
using BTCPayServer.Models.NotificationViewModels;
using Microsoft.AspNetCore.Routing;

View File

@ -1,14 +1,10 @@
using System;
using BTCPayServer.Contracts;
using BTCPayServer.Models.NotificationViewModels;
namespace BTCPayServer.Services.Notifications
{
public interface INotificationHandler
{
string NotificationType { get; }
Type NotificationBlobType { get; }
void FillViewModel(object notification, NotificationViewModel vm);
}
public abstract class NotificationHandler<TNotification> : INotificationHandler
{
public abstract string NotificationType { get; }

View File

@ -4,6 +4,7 @@ using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using BTCPayServer.Components.NotificationsDropdown;
using BTCPayServer.Contracts;
using BTCPayServer.Data;
using BTCPayServer.Models.NotificationViewModels;
using Microsoft.AspNetCore.Identity;

View File

@ -0,0 +1,153 @@
@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

@ -33,6 +33,16 @@
<button name="command" type="submit" class="btn btn-primary" value="update" disabled="@(Model.CanUseSSH ? null : "disabled")">Update</button>
</div>
</div>
<h5>Restart</h5>
<p class="text-secondary mb-2">Restart BTCPay server and related services.</p>
<div class="form-group">
<div class="input-group">
<button name="command" type="submit" class="btn btn-primary" value="restart" disabled="@(Model.CanUseSSH ? null : "disabled")">Restart</button>
</div>
</div>
<h5 class="mt-5">Clean</h5>
<p class="text-secondary mb-2">Delete unused docker images present on your system.</p>

View File

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

View File

@ -1,10 +1,16 @@
<div class="nav flex-column nav-pills mb-4">
@using BTCPayServer.Configuration
@inject BTCPayServerOptions BTCPayServerOptions
<div class="nav flex-column nav-pills mb-4">
<a asp-controller="Server" id="Server-@ServerNavPages.Users" class="nav-link @ViewData.IsActivePage(ServerNavPages.Users)" asp-action="ListUsers">Users</a>
<a asp-controller="Server" id="Server-@ServerNavPages.Emails" class="nav-link @ViewData.IsActivePage(ServerNavPages.Emails)" asp-action="Emails">Email server</a>
<a asp-controller="Server" id="Server-@ServerNavPages.Policies" class="nav-link @ViewData.IsActivePage(ServerNavPages.Policies)" asp-action="Policies">Policies</a>
<a asp-controller="Server" id="Server-@ServerNavPages.Services" class="nav-link @ViewData.IsActivePage(ServerNavPages.Services)" asp-action="Services">Services</a>
<a asp-controller="Server" id="Server-@ServerNavPages.Theme" class="nav-link @ViewData.IsActivePage(ServerNavPages.Theme)" asp-action="Theme">Theme</a>
<a asp-controller="Server" id="Server-@ServerNavPages.Maintenance" class="nav-link @ViewData.IsActivePage(ServerNavPages.Maintenance)" asp-action="Maintenance">Maintenance</a>
@if (BTCPayServerOptions.DockerDeployment)
{
<a asp-controller="Server" id="Server-@ServerNavPages.Maintenance" class="nav-link @ViewData.IsActivePage(ServerNavPages.Maintenance)" asp-action="Maintenance">Maintenance</a>
}
<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>
</div>

View File

@ -40,6 +40,12 @@
</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"/>
}
@if (SignInManager.IsSignedIn(User))
{
if (User.IsInRole(Roles.ServerAdmin))

View File

@ -6,8 +6,8 @@
<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.IStoreNavExtension> Extensions;
@foreach (var extension in Extensions)
@inject IEnumerable<BTCPayServer.Contracts.INavExtension> Extensions;
@foreach (var extension in Extensions.Where(extension => extension.Location == "store"))
{
<partial name="@extension.Partial" />
}

View File

@ -28,6 +28,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BTCPayServer.Data", "BTCPay
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BTCPayServer.Client", "BTCPayServer.Client\BTCPayServer.Client.csproj", "{21A13304-7168-49A0-86C2-0A1A9453E9C7}"
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}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Altcoins-Debug|Any CPU = Altcoins-Debug|Any CPU
@ -188,6 +192,54 @@ Global
{21A13304-7168-49A0-86C2-0A1A9453E9C7}.Release|x64.Build.0 = Release|Any CPU
{21A13304-7168-49A0-86C2-0A1A9453E9C7}.Release|x86.ActiveCfg = Release|Any CPU
{21A13304-7168-49A0-86C2-0A1A9453E9C7}.Release|x86.Build.0 = Release|Any CPU
{A0D50BB6-FE2C-4671-8693-F7582B66178F}.Altcoins-Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A0D50BB6-FE2C-4671-8693-F7582B66178F}.Altcoins-Debug|Any CPU.Build.0 = Debug|Any CPU
{A0D50BB6-FE2C-4671-8693-F7582B66178F}.Altcoins-Debug|x64.ActiveCfg = Debug|Any CPU
{A0D50BB6-FE2C-4671-8693-F7582B66178F}.Altcoins-Debug|x64.Build.0 = Debug|Any CPU
{A0D50BB6-FE2C-4671-8693-F7582B66178F}.Altcoins-Debug|x86.ActiveCfg = Debug|Any CPU
{A0D50BB6-FE2C-4671-8693-F7582B66178F}.Altcoins-Debug|x86.Build.0 = Debug|Any CPU
{A0D50BB6-FE2C-4671-8693-F7582B66178F}.Altcoins-Release|Any CPU.ActiveCfg = Debug|Any CPU
{A0D50BB6-FE2C-4671-8693-F7582B66178F}.Altcoins-Release|Any CPU.Build.0 = Debug|Any CPU
{A0D50BB6-FE2C-4671-8693-F7582B66178F}.Altcoins-Release|x64.ActiveCfg = Debug|Any CPU
{A0D50BB6-FE2C-4671-8693-F7582B66178F}.Altcoins-Release|x64.Build.0 = Debug|Any CPU
{A0D50BB6-FE2C-4671-8693-F7582B66178F}.Altcoins-Release|x86.ActiveCfg = Debug|Any CPU
{A0D50BB6-FE2C-4671-8693-F7582B66178F}.Altcoins-Release|x86.Build.0 = Debug|Any CPU
{A0D50BB6-FE2C-4671-8693-F7582B66178F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A0D50BB6-FE2C-4671-8693-F7582B66178F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A0D50BB6-FE2C-4671-8693-F7582B66178F}.Debug|x64.ActiveCfg = Debug|Any CPU
{A0D50BB6-FE2C-4671-8693-F7582B66178F}.Debug|x64.Build.0 = Debug|Any CPU
{A0D50BB6-FE2C-4671-8693-F7582B66178F}.Debug|x86.ActiveCfg = Debug|Any CPU
{A0D50BB6-FE2C-4671-8693-F7582B66178F}.Debug|x86.Build.0 = Debug|Any CPU
{A0D50BB6-FE2C-4671-8693-F7582B66178F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A0D50BB6-FE2C-4671-8693-F7582B66178F}.Release|Any CPU.Build.0 = Release|Any CPU
{A0D50BB6-FE2C-4671-8693-F7582B66178F}.Release|x64.ActiveCfg = Release|Any CPU
{A0D50BB6-FE2C-4671-8693-F7582B66178F}.Release|x64.Build.0 = Release|Any CPU
{A0D50BB6-FE2C-4671-8693-F7582B66178F}.Release|x86.ActiveCfg = Release|Any CPU
{A0D50BB6-FE2C-4671-8693-F7582B66178F}.Release|x86.Build.0 = Release|Any CPU
{545AFC8E-7BC2-43D9-84CA-F9468F4FF295}.Altcoins-Debug|Any CPU.ActiveCfg = Debug|Any CPU
{545AFC8E-7BC2-43D9-84CA-F9468F4FF295}.Altcoins-Debug|Any CPU.Build.0 = Debug|Any CPU
{545AFC8E-7BC2-43D9-84CA-F9468F4FF295}.Altcoins-Debug|x64.ActiveCfg = Debug|Any CPU
{545AFC8E-7BC2-43D9-84CA-F9468F4FF295}.Altcoins-Debug|x64.Build.0 = Debug|Any CPU
{545AFC8E-7BC2-43D9-84CA-F9468F4FF295}.Altcoins-Debug|x86.ActiveCfg = Debug|Any CPU
{545AFC8E-7BC2-43D9-84CA-F9468F4FF295}.Altcoins-Debug|x86.Build.0 = Debug|Any CPU
{545AFC8E-7BC2-43D9-84CA-F9468F4FF295}.Altcoins-Release|Any CPU.ActiveCfg = Debug|Any CPU
{545AFC8E-7BC2-43D9-84CA-F9468F4FF295}.Altcoins-Release|Any CPU.Build.0 = Debug|Any CPU
{545AFC8E-7BC2-43D9-84CA-F9468F4FF295}.Altcoins-Release|x64.ActiveCfg = Debug|Any CPU
{545AFC8E-7BC2-43D9-84CA-F9468F4FF295}.Altcoins-Release|x64.Build.0 = Debug|Any CPU
{545AFC8E-7BC2-43D9-84CA-F9468F4FF295}.Altcoins-Release|x86.ActiveCfg = Debug|Any CPU
{545AFC8E-7BC2-43D9-84CA-F9468F4FF295}.Altcoins-Release|x86.Build.0 = Debug|Any CPU
{545AFC8E-7BC2-43D9-84CA-F9468F4FF295}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{545AFC8E-7BC2-43D9-84CA-F9468F4FF295}.Debug|Any CPU.Build.0 = Debug|Any CPU
{545AFC8E-7BC2-43D9-84CA-F9468F4FF295}.Debug|x64.ActiveCfg = Debug|Any CPU
{545AFC8E-7BC2-43D9-84CA-F9468F4FF295}.Debug|x64.Build.0 = Debug|Any CPU
{545AFC8E-7BC2-43D9-84CA-F9468F4FF295}.Debug|x86.ActiveCfg = Debug|Any CPU
{545AFC8E-7BC2-43D9-84CA-F9468F4FF295}.Debug|x86.Build.0 = Debug|Any CPU
{545AFC8E-7BC2-43D9-84CA-F9468F4FF295}.Release|Any CPU.ActiveCfg = Release|Any CPU
{545AFC8E-7BC2-43D9-84CA-F9468F4FF295}.Release|Any CPU.Build.0 = Release|Any CPU
{545AFC8E-7BC2-43D9-84CA-F9468F4FF295}.Release|x64.ActiveCfg = Release|Any CPU
{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
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE