feat: embed plugin directory in plugin management (#7381)
What changed, and why it matters
This commit refactors BTCPay Server's plugin management into a dedicated plugin and adds an embedded plugin directory that loads content from an external plugin catalog via an iframe. The change moves plugin URLs from the server settings area to a new plugin area, adds URL sanitization for author and documentation links, and introduces checks to prevent installing mismatched plugin identifiers. It is a feature/refactoring commit rather than a disclosed security fix, though it includes some defensive hardening.
Review the new UIPluginManagerController and PluginService for authorization bypasses, validate that iframe sandboxing/CSP restrictions are applied to the embedded plugin directory, ensure the plugin source URL cannot be redirected to attacker-controlled origins, and audit SafeExternalUrl coverage for all user-controlled plugin metadata fields rendered in views.
Security signals we found
New SafeExternalUrl helper filters plugin metadata URLs to http/https absolute URIs only, mitigating javascript: and relative-link injection in author/documentation fields
DownloadRemotePlugin now validates that the downloaded manifest identifier matches the requested plugin identifier
Plugin directory is rendered via iframe to an external, configurable plugin source, increasing attack surface if the source is compromised or MITM'd
Plugin source URL normalization strips query strings and fragments before building iframe and API URLs
Controller actions are protected by Policies.CanModifyServerSettings authorization
Removal of PluginUpdateFetcher scheduled task and PluginUpdateNotification handler reduces server-side update polling
Evidence from the diff
The commit moves plugin management out of UIServerController into a new PluginManager plugin with UIPluginManagerController. It embeds the plugin directory as an iframe pointing to a configurable plugin source (default plugin builder catalog), passing btcpayVersion and includePreRelease query parameters. New defensive measures include SafeExternalUrl() to strip non-http(s) author/documentation links, identifier-mismatch validation in DownloadRemotePlugin, and dependency-aware version selection. The old server-side plugin list and update notification fetcher are removed. Several tests are added/updated to cover these behaviors.
Changed components
BTCPayServer/Plugins/PluginManager/Controllers/UIPluginManagerController.csBTCPayServer/Plugins/PluginManager/PluginService.csBTCPayServer/Plugins/PluginManager/PluginBuilderClient.csBTCPayServer/Plugins/PluginManager/Views/PluginDirectory.cshtmlBTCPayServer/Plugins/PluginManager/Views/ListPlugins.cshtmlBTCPayServer/Components/GlobalNav/Default.cshtmlBTCPayServer/Controllers/UIServerController.Plugins.cs (removed)BTCPayServer/HostedServices/PluginUpdateFetcher.cs (removed)BTCPayServer/Hosting/BTCPayServerServices.csBTCPayServer.Tests/PluginManagerTests.csInspect captured patch +3018 / −1692
diff --git a/BTCPayServer.Tests/FastTests.cs b/BTCPayServer.Tests/FastTests.cs
index 8664943..5f4497a 100644
--- a/BTCPayServer.Tests/FastTests.cs
+++ b/BTCPayServer.Tests/FastTests.cs
@@ -12,7 +12,6 @@ using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Controllers;
-using BTCPayServer.Plugins;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Configuration;
@@ -2692,114 +2691,5 @@ bc1qfzu57kgu5jthl934f9xrdzzx8mmemx7gn07tf0grnvz504j6kzusu2v0ku
Assert.Equal("BTC-hasjdfhasjkfjlajn", new PaymentMethodIdJsonConverter().ReadJson(reader, typeof(PaymentMethodId), null,
JsonSerializer.CreateDefault()).ToString());
}
-
- [Fact]
- public void GetDisabledPluginUpdates_ReturnsUpdateWhenNewerVersionAvailable()
- {
- var disabled = new Dictionary<string, Version> { { "TestPlugin", new Version(1, 0, 0, 0) } };
- var available = new Dictionary<string, PluginService.AvailablePlugin>()
- {
- { "TestPlugin", MakeAvailablePlugin("TestPlugin", "1.1.0") }
- };
-
- var result = UIServerController.ListPluginsViewModel.GetDisabledPluginUpdates(disabled, available);
-
- Assert.Single(result);
- Assert.Equal(new Version(1, 1, 0), result["TestPlugin"].Version);
- }
-
- [Fact]
- public void GetDisabledPluginUpdates_NoUpdateWhenSameVersion()
- {
- var disabled = new Dictionary<string, Version> { { "TestPlugin", new Version(1, 0, 0, 0) } };
- var available = new Dictionary<string, PluginService.AvailablePlugin>()
- {
- { "TestPlugin", MakeAvailablePlugin("TestPlugin", "1.0.0") }
- };
-
- var result = UIServerController.ListPluginsViewModel.GetDisabledPluginUpdates(disabled, available);
-
- Assert.Empty(result);
- }
-
- [Fact]
- public void GetDisabledPluginUpdates_NoUpdateWhenNoAvailablePlugins()
- {
- var disabled = new Dictionary<string, Version> { { "TestPlugin", new Version(1, 0, 0, 0) } };
- var available = new Dictionary<string, PluginService.AvailablePlugin>();
-
- var result = UIServerController.ListPluginsViewModel.GetDisabledPluginUpdates(disabled, available);
-
- Assert.Empty(result);
- }
-
- [Fact]
- public void GetDisabledPluginUpdates_SkipsNullVersion()
- {
- var disabled = new Dictionary<string, Version> { { "TestPlugin", null } };
- var available = new Dictionary<string, PluginService.AvailablePlugin>()
- {
- { "TestPlugin", MakeAvailablePlugin("TestPlugin", "1.1.0") }
- };
-
- var result = UIServerController.ListPluginsViewModel.GetDisabledPluginUpdates(disabled, available);
-
- Assert.Empty(result);
- }
-
- [Fact]
- public void GetDisabledPluginUpdates_CaseInsensitiveIdentifierMatching()
- {
- var disabled = new Dictionary<string, Version> { { "MyPlugin", new Version(1, 0, 0, 0) } };
- var available = new Dictionary<string, PluginService.AvailablePlugin>(StringComparer.OrdinalIgnoreCase)
- {
- { "myplugin", MakeAvailablePlugin("myplugin", "1.1.0") }
- };
-
- var result = UIServerController.ListPluginsViewModel.GetDisabledPluginUpdates(disabled, available);
-
- Assert.Single(result);
- Assert.Equal(new Version(1, 1, 0), result["MyPlugin"].Version);
- }
-
- [Fact]
- public void GetDisabledPluginUpdates_UsesNewestVersionFromMultipleEntries()
- {
- var disabled = new Dictionary<string, Version> { { "TestPlugin", new Version(1, 0, 0, 0) } };
- // Build the dictionary the same way the controller does
- var allPlugins = new[]
- {
- MakeAvailablePlugin("TestPlugin", "1.1.0"),
- MakeAvailablePlugin("TestPlugin", "1.3.0"),
- MakeAvailablePlugin("TestPlugin", "1.2.0")
- };
- var available = new Dictionary<string, PluginService.AvailablePlugin>(StringComparer.OrdinalIgnoreCase);
- foreach (var p in allPlugins)
- {
- if (!available.TryGetValue(p.Identifier, out var existing) || p.Version > existing.Version)
- available[p.Identifier] = p;
- }
-
- var result = UIServerController.ListPluginsViewModel.GetDisabledPluginUpdates(disabled, available);
-
- Assert.Single(result);
- Assert.Equal(new Version(1, 3, 0), result["TestPlugin"].Version);
- }
-
- private static PluginService.AvailablePlugin MakeAvailablePlugin(
- string identifier, string version, params (string id, string condition)[] dependencies)
- {
- return new PluginService.AvailablePlugin
- {
- Identifier = identifier,
- Name = identifier,
- Version = Version.Parse(version),
- Dependencies = dependencies.Select(d => new IBTCPayServerPlugin.PluginDependency
- {
- Identifier = d.id,
- Condition = d.condition
- }).ToArray()
- };
- }
}
}
diff --git a/BTCPayServer.Tests/LanguageServiceTests.cs b/BTCPayServer.Tests/LanguageServiceTests.cs
index 2d73d63..414c98a 100644
--- a/BTCPayServer.Tests/LanguageServiceTests.cs
+++ b/BTCPayServer.Tests/LanguageServiceTests.cs
@@ -5,7 +5,6 @@ using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
-using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Abstractions.Models;
@@ -13,6 +12,7 @@ using BTCPayServer.Plugins.Translations;
using BTCPayServer.Services;
using Dapper;
using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Caching.Memory;
using Microsoft.Playwright;
using Newtonsoft.Json.Linq;
using static Microsoft.Playwright.Assertions;
@@ -23,82 +23,56 @@ namespace BTCPayServer.Tests
[Collection(nameof(NonParallelizableCollectionDefinition))]
public class LanguageServiceTests : UnitTestBase
{
- private class StubHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory
- {
- public HttpClient CreateClient(string name) => new(handler, false);
- }
+ private const string ManifestUrl = "https://raw.githubusercontent.com/btcpayserver/btcpayserver-translator/main/manifest.json";
private class RealHttpClientFactory : IHttpClientFactory
{
public HttpClient CreateClient(string name) => new();
}
+ public const int TestTimeout = TestUtils.TestTimeout;
- private class StubHttpMessageHandler : HttpMessageHandler
+ private sealed class TestHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory
{
- private readonly Dictionary<string, Func<HttpResponseMessage>> _responses = new();
- public Dictionary<string, int> Calls { get; } = new();
-
- public void Register(string url, Func<HttpResponseMessage> responseFactory)
- {
- _responses[url] = responseFactory;
- }
-
- protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
- {
- var url = request.RequestUri!.ToString();
- Calls[url] = Calls.TryGetValue(url, out var existing) ? existing + 1 : 1;
-
- if (_responses.TryGetValue(url, out var responseFactory))
- return Task.FromResult(responseFactory());
-
- return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
- {
- Content = new StringContent("Not Found", Encoding.UTF8, "text/plain")
- });
- }
+ public HttpClient CreateClient(string name) => new(handler, false);
}
- public const int TestTimeout = TestUtils.TestTimeout;
public LanguageServiceTests(ITestOutputHelper helper) : base(helper)
{
}
+ private static LanguagePackUpdateService CreateLanguageService(HttpMessageHandler handler) =>
+ new(new TestHttpClientFactory(handler), new MemoryCache(new MemoryCacheOptions()));
+
[Fact(Timeout = TestTimeout)]
[Trait("Unit", "Unit")]
public async Task LanguagePackUpdateService_ParsesManifestEntries()
{
- var handler = new StubHttpMessageHandler();
- var manifestUrl = "https://raw.githubusercontent.com/btcpayserver/btcpayserver-translator/main/manifest.json";
- handler.Register(manifestUrl, () => new HttpResponseMessage(HttpStatusCode.OK)
- {
- Content = new StringContent(
- """
+ var handler = new TestHttpMessageHandler();
+ handler.Register(ManifestUrl, () => TestHttpMessageHandler.JsonResponse(
+ """
+ {
+ "Languages": [
+ {
+ "Name": "French",
+ "Native": "Francais",
+ "File": "translations/french.json",
+ "Sha": "sha-fr",
+ "Maintainer": "alice|https://github.com/alice",
+ "Updated": "2026-05-01T10:00:00Z"
+ },
{
- "Languages": [
- {
- "Name": "French",
- "Native": "Francais",
- "File": "translations/french.json",
- "Sha": "sha-fr",
- "Maintainer": "alice|https://github.com/alice",
- "Updated": "2026-05-01T10:00:00Z"
- },
- {
- "Name": "German",
- "Native": "Deutsch",
- "File": "translations/german.json",
- "Sha": "sha-de",
- "Maintainer": null,
- "Updated": "invalid"
- }
- ]
+ "Name": "German",
+ "Native": "Deutsch",
+ "File": "translations/german.json",
+ "Sha": "sha-de",
+ "Maintainer": null,
+ "Updated": "invalid"
}
- """,
- Encoding.UTF8,
- "application/json")
- });
+ ]
+ }
+ """));
- var service = new LanguagePackUpdateService(new StubHttpClientFactory(handler), new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
+ var service = CreateLanguageService(handler);
var languages = await service.GetManifestLanguages();
Assert.Equal(2, languages.Length);
@@ -147,14 +121,11 @@ namespace BTCPayServer.Tests
[Trait("Unit", "Unit")]
public async Task LanguagePackUpdateService_PropagatesExceptionOnMalformedManifest()
{
- var handler = new StubHttpMessageHandler();
- var manifestUrl = "https://raw.githubusercontent.com/btcpayserver/btcpayserver-translator/main/manifest.json";
- handler.Register(manifestUrl, () => new HttpResponseMessage(HttpStatusCode.OK)
- {
- Content = new StringContent("{ this is not: valid json", Encoding.UTF8, "application/json")
- });
+ var handler = new TestHttpMessageHandler();
+ handler.Register(ManifestUrl, () =>
+ TestHttpMessageHandler.JsonResponse("{ this is not: valid json"));
- var service = new LanguagePackUpdateService(new StubHttpClientFactory(handler), new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
+ var service = CreateLanguageService(handler);
await Assert.ThrowsAnyAsync<Exception>(() => service.GetManifestLanguages());
}
@@ -162,14 +133,11 @@ namespace BTCPayServer.Tests
[Trait("Unit", "Unit")]
public async Task LanguagePackUpdateService_ThrowsOnMissingLanguagesKey()
{
- var handler = new StubHttpMessageHandler();
- var manifestUrl = "https://raw.githubusercontent.com/btcpayserver/btcpayserver-translator/main/manifest.json";
- handler.Register(manifestUrl, () => new HttpResponseMessage(HttpStatusCode.OK)
- {
- Content = new StringContent("{ \"OtherField\": [] }", Encoding.UTF8, "application/json")
- });
+ var handler = new TestHttpMessageHandler();
+ handler.Register(ManifestUrl, () =>
+ TestHttpMessageHandler.JsonResponse("{ \"OtherField\": [] }"));
- var service = new LanguagePackUpdateService(new StubHttpClientFactory(handler), new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
+ var service = CreateLanguageService(handler);
await Assert.ThrowsAsync<InvalidOperationException>(() => service.GetManifestLanguages());
}
@@ -177,16 +145,11 @@ namespace BTCPayServer.Tests
[Trait("Unit", "Unit")]
public async Task LanguagePackUpdateService_ThrowsArgumentExceptionForUnknownLanguage()
{
- var handler = new StubHttpMessageHandler();
- var manifestUrl = "https://raw.githubusercontent.com/btcpayserver/btcpayserver-translator/main/manifest.json";
- handler.Register(manifestUrl, () => new HttpResponseMessage(HttpStatusCode.OK)
- {
- Content = new StringContent(
- """{ "Languages": [ { "Name": "French", "File": "translations/french.json", "Sha": "deadbeef" } ] }""",
- Encoding.UTF8, "application/json")
- });
+ var handler = new TestHttpMessageHandler();
+ handler.Register(ManifestUrl, () => TestHttpMessageHandler.JsonResponse(
+ """{ "Languages": [ { "Name": "French", "File": "translations/french.json", "Sha": "deadbeef" } ] }"""));
- var service = new LanguagePackUpdateService(new StubHttpClientFactory(handler), new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
+ var service = CreateLanguageService(handler);
await Assert.ThrowsAsync<ArgumentException>(
() => service.FetchLanguagePackFromRepository("Klingon"));
}
@@ -195,14 +158,13 @@ namespace BTCPayServer.Tests
[Trait("Unit", "Unit")]
public async Task LanguagePackUpdateService_ThrowsWhenManifestFails()
{
- var handler = new StubHttpMessageHandler();
- var manifestUrl = "https://raw.githubusercontent.com/btcpayserver/btcpayserver-translator/main/manifest.json";
- handler.Register(manifestUrl, () => new HttpResponseMessage(HttpStatusCode.InternalServerError)
+ var handler = new TestHttpMessageHandler();
+ handler.Register(ManifestUrl, () => new HttpResponseMessage(HttpStatusCode.InternalServerError)
{
Content = new StringContent("error", Encoding.UTF8, "text/plain")
});
- var service = new LanguagePackUpdateService(new StubHttpClientFactory(handler), new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
+ var service = CreateLanguageService(handler);
await Assert.ThrowsAnyAsync<HttpRequestException>(() => service.GetManifestLanguages());
}
@@ -213,33 +175,24 @@ namespace BTCPayServer.Tests
const string body = "{\"Hello\":\"Bonjour\"}";
var expectedSha = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(body)));
- var handler = new StubHttpMessageHandler();
- var manifestUrl = "https://raw.githubusercontent.com/btcpayserver/btcpayserver-translator/main/manifest.json";
+ var handler = new TestHttpMessageHandler();
var translationUrl = "https://raw.githubusercontent.com/btcpayserver/btcpayserver-translator/main/translations/french.json";
- handler.Register(manifestUrl, () => new HttpResponseMessage(HttpStatusCode.OK)
- {
- Content = new StringContent(
- $$"""
+ handler.Register(ManifestUrl, () => TestHttpMessageHandler.JsonResponse(
+ $$"""
+ {
+ "Languages": [
{
- "Languages": [
- {
- "Name": "French",
- "File": "translations/french.json",
- "Sha": "{{expectedSha}}"
- }
- ]
+ "Name": "French",
+ "File": "translations/french.json",
+ "Sha": "{{expectedSha}}"
}
- """,
- Encoding.UTF8,
- "application/json")
- });
- handler.Register(translationUrl, () => new HttpResponseMessage(HttpStatusCode.OK)
- {
- Content = new StringContent(body, Encoding.UTF8, "application/json")
- });
+ ]
+ }
+ """));
+ handler.Register(translationUrl, () => TestHttpMessageHandler.JsonResponse(body));
- var service = new LanguagePackUpdateService(new StubHttpClientFactory(handler), new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
+ var service = CreateLanguageService(handler);
var (translationsJson, version, rtl) = await service.FetchLanguagePackFromRepository("French");
Assert.Equal(expectedSha, version, ignoreCase: true);
@@ -253,33 +206,24 @@ namespace BTCPayServer.Tests
{
const string body = "{\"Hello\":\"Bonjour\"}";
- var handler = new StubHttpMessageHandler();
- var manifestUrl = "https://raw.githubusercontent.com/btcpayserver/btcpayserver-translator/main/manifest.json";
+ var handler = new TestHttpMessageHandler();
var translationUrl = "https://raw.githubusercontent.com/btcpayserver/btcpayserver-translator/main/translations/french.json";
- handler.Register(manifestUrl, () => new HttpResponseMessage(HttpStatusCode.OK)
- {
- Content = new StringContent(
- """
+ handler.Register(ManifestUrl, () => TestHttpMessageHandler.JsonResponse(
+ """
+ {
+ "Languages": [
{
- "Languages": [
- {
- "Name": "French",
- "File": "translations/french.json",
- "Sha": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
- }
- ]
+ "Name": "French",
+ "File": "translations/french.json",
+ "Sha": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
}
- """,
- Encoding.UTF8,
- "application/json")
- });
- handler.Register(translationUrl, () => new HttpResponseMessage(HttpStatusCode.OK)
- {
- Content = new StringContent(body, Encoding.UTF8, "application/json")
- });
+ ]
+ }
+ """));
+ handler.Register(translationUrl, () => TestHttpMessageHandler.JsonResponse(body));
- var service = new LanguagePackUpdateService(new StubHttpClientFactory(handler), new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
+ var service = CreateLanguageService(handler);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => service.FetchLanguagePackFromRepository("French"));
Assert.Contains("SHA-256 mismatch", ex.Message);
@@ -289,28 +233,22 @@ namespace BTCPayServer.Tests
[Trait("Unit", "Unit")]
public async Task LanguagePackUpdateService_UsesUpdateCacheUntilInvalidated()
{
- var handler = new StubHttpMessageHandler();
- var manifestUrl = "https://raw.githubusercontent.com/btcpayserver/btcpayserver-translator/main/manifest.json";
+ var handler = new TestHttpMessageHandler();
- handler.Register(manifestUrl, () => new HttpResponseMessage(HttpStatusCode.OK)
- {
- Content = new StringContent(
- """
+ handler.Register(ManifestUrl, () => TestHttpMessageHandler.JsonResponse(
+ """
+ {
+ "Languages": [
{
- "Languages": [
- {
- "Name": "French",
- "File": "translations/french.json",
- "Sha": "sha-1"
- }
- ]
+ "Name": "French",
+ "File": "translations/french.json",
+ "Sha": "sha-1"
}
- """,
- Encoding.UTF8,
- "application/json")
- });
+ ]
+ }
+ """));
- var service = new LanguagePackUpdateService(new StubHttpClientFactory(handler), new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
+ var service = CreateLanguageService(handler);
var outdatedMetadata = JObject.Parse("{ \"version\": \"sha-0\" }");
var upToDateMetadata = JObject.Parse("{ \"version\": \"sha-1\" }");
diff --git a/BTCPayServer.Tests/PlaywrightTester.cs b/BTCPayServer.Tests/PlaywrightTester.cs
index e7cd1af..72f2431 100644
--- a/BTCPayServer.Tests/PlaywrightTester.cs
+++ b/BTCPayServer.Tests/PlaywrightTester.cs
@@ -410,13 +410,6 @@ namespace BTCPayServer.Tests
public async Task GoToServer(string navPages)
{
- if (navPages == nameof(ServerNavPages.Plugins))
- {
- await Page.ClickAsync("#globalNavPluginsToggle");
- await Page.ClickAsync("#globalNavPluginsMenu #menu-item-Plugins");
- return;
- }
-
await Page.ClickAsync("#globalNavServerToggle");
if (navPages == nameof(ServerNavPages.Emails))
{
diff --git a/BTCPayServer.Tests/PluginManagerTests.cs b/BTCPayServer.Tests/PluginManagerTests.cs
new file mode 100644
index 0000000..61819d0
--- /dev/null
+++ b/BTCPayServer.Tests/PluginManagerTests.cs
@@ -0,0 +1,620 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Threading.Tasks;
+using BTCPayServer.Abstractions.Contracts;
+using BTCPayServer.Abstractions.Models;
+using BTCPayServer.Configuration;
+using BTCPayServer.Plugins;
+using BTCPayServer.Plugins.PluginManagement;
+using BTCPayServer.Plugins.PluginManagement.Controllers;
+using BTCPayServer.Plugins.PluginManagement.Models;
+using BTCPayServer.Services;
+using Microsoft.Extensions.Options;
+using NBitcoin;
+using Newtonsoft.Json;
+using Xunit;
+
+namespace BTCPayServer.Tests
+{
+ [Trait("Fast", "Fast")]
+ public class PluginManagerTests : UnitTestBase
+ {
+ public PluginManagerTests(ITestOutputHelper helper) : base(helper)
+ {
+ }
+
+ [Fact]
+ public async Task InstalledPluginsViewModel_ReturnsDisabledPluginUpdateWhenNewerVersionAvailable()
+ {
+ var model = await CreateInstalledPluginsViewModel(
+ disabled: new Dictionary<string, Version> { { "TestPlugin", new Version(1, 0, 0, 0) } },
+ allAvailable: [MakeAvailablePlugin("TestPlugin", "1.1.0")]);
+
+ var plugin = Assert.Single(model.DisabledPlugins);
+ Assert.Equal(new Version(1, 1, 0), plugin.RecommendedUpdateVersion);
+ var updateAction = Assert.Single(plugin.Actions, action => action.FormAction == "InstallPlugin");
+ Assert.Equal("1.1.0", updateAction.Version);
+ }
+
+ [Fact]
+ public async Task InstalledPluginsViewModel_DoesNotAddDisabledPluginUpdateWhenSameVersion()
+ {
+ var model = await CreateInstalledPluginsViewModel(
+ disabled: new Dictionary<string, Version> { { "TestPlugin", new Version(1, 0, 0, 0) } },
+ allAvailable: [MakeAvailablePlugin("TestPlugin", "1.0.0")]);
+
+ var plugin = Assert.Single(model.DisabledPlugins);
+ Assert.Null(plugin.RecommendedUpdateVersion);
+ Assert.DoesNotContain(plugin.Actions, action => action.FormAction == "InstallPlugin");
+ }
+
+ [Fact]
+ public async Task InstalledPluginsViewModel_DoesNotAddDisabledPluginUpdateWhenNoRemotePlugins()
+ {
+ var model = await CreateInstalledPluginsViewModel(
+ disabled: new Dictionary<string, Version> { { "TestPlugin", new Version(1, 0, 0, 0) } },
+ allAvailable: []);
+
+ var plugin = Assert.Single(model.DisabledPlugins);
+ Assert.Null(plugin.RecommendedUpdateVersion);
+ Assert.DoesNotContain(plugin.Actions, action => action.FormAction == "InstallPlugin");
+ }
+
+ [Fact]
+ public async Task InstalledPluginsViewModel_SkipsDisabledPluginUpdateWhenVersionIsUnknown()
+ {
+ var model = await CreateInstalledPluginsViewModel(
+ disabled: new Dictionary<string, Version> { { "TestPlugin", null } },
+ allAvailable: [MakeAvailablePlugin("TestPlugin", "1.1.0")]);
+
+ var plugin = Assert.Single(model.DisabledPlugins);
+ Assert.Null(plugin.RecommendedUpdateVersion);
+ Assert.DoesNotContain(plugin.Actions, action => action.FormAction == "InstallPlugin");
+ }
+
+ [Fact]
+ public async Task InstalledPluginsViewModel_UsesCaseInsensitiveDisabledPluginIdentifierMatching()
+ {
+ var model = await CreateInstalledPluginsViewModel(
+ disabled: new Dictionary<string, Version> { { "MyPlugin", new Version(1, 0, 0, 0) } },
+ allAvailable: [MakeAvailablePlugin("myplugin", "1.1.0")]);
+
+ var plugin = Assert.Single(model.DisabledPlugins);
+ Assert.Equal(new Version(1, 1, 0), plugin.RecommendedUpdateVersion);
+ var updateAction = Assert.Single(plugin.Actions, action => action.FormAction == "InstallPlugin");
+ Assert.Equal("1.1.0", updateAction.Version);
+ }
+
+ [Fact]
+ public async Task InstalledPluginsViewModel_UsesNewestVersionFromMultipleEntries()
+ {
+ var model = await CreateInstalledPluginsViewModel(
+ disabled: new Dictionary<string, Version> { { "TestPlugin", new Version(1, 0, 0, 0) } },
+ allAvailable: [
+ MakeAvailablePlugin("TestPlugin", "1.1.0"),
+ MakeAvailablePlugin("TestPlugin", "1.3.0"),
+ MakeAvailablePlugin("TestPlugin", "1.2.0")
+ ]);
+
+ var plugin = Assert.Single(model.DisabledPlugins);
+ Assert.Equal(new Version(1, 3, 0), plugin.RecommendedUpdateVersion);
+ var updateAction = Assert.Single(plugin.Actions, action => action.FormAction == "InstallPlugin");
+ Assert.Equal("1.3.0", updateAction.Version);
+ }
+
+ [Fact]
+ public void PluginDirectoryViewModel_PrefersHighestAvailableVersionWithSatisfiedDependencies()
+ {
+ var model = CreatePluginDirectoryViewModel(
+ selectedSlug: "testplugin",
+ allAvailable: [
+ MakeAvailablePlugin("TestPlugin", "2.0.0", ("MissingDependency", ">=1.0.0")),
+ MakeAvailablePlugin("TestPlugin", "1.5.0")
+ ]);
+
+ Assert.Equal("1.5.0", model.SelectedPluginPanel.InstallVersion);
+ }
+
+ [Fact]
+ public void PluginVersionSelection_UsesRequestedCompatibleVersion()
+ {
+ var selectedVersion = PluginService.SelectCompatiblePluginVersion(
+ "TestPlugin",
+ "1.5.0",
+ null,
+ [
+ MakeAvailablePlugin("TestPlugin", "1.5.0"),
+ MakeAvailablePlugin("TestPlugin", "1.4.0")
+ ]);
+
+ Assert.Equal(new Version(1, 5, 0), selectedVersion);
+ }
+
+ [Fact]
+ public void PluginVersionSelection_RejectsRequestedVersionOutsideCompatibleSet()
+ {
+ var ex = Assert.Throws<InvalidOperationException>(() =>
+ PluginService.SelectCompatiblePluginVersion(
+ "TestPlugin",
+ "2.0.0",
+ null,
+ [MakeAvailablePlugin("TestPlugin", "1.5.0")]));
+
+ Assert.Contains("not compatible", ex.Message);
+ }
+
+ [Fact]
+ public void PluginVersionSelection_DoesNotUseVersionsFromOtherIdentifiers()
+ {
+ var ex = Assert.Throws<InvalidOperationException>(() =>
+ PluginService.SelectCompatiblePluginVersion(
+ "TestPlugin",
+ "2.0.0",
+ null,
+ [
+ MakeAvailablePlugin("OtherPlugin", "2.0.0"),
+ MakeAvailablePlugin("TestPlugin", "1.5.0")
+ ]));
+
+ Assert.Contains("not compatible", ex.Message);
+ }
+
+ [Fact]
+ public async Task DownloadRemotePlugin_RejectsManifestIdentifierMismatch()
+ {
+ using var httpClient = new HttpClient(new TestHttpMessageHandler(request =>
+ {
+ var path = request.RequestUri!.AbsolutePath;
+ if (path == "/api/v1/plugins/TestPlugin")
+ {
+ return TestHttpMessageHandler.JsonResponse("""
+ [{
+ "projectSlug": "test-plugin",
+ "buildId": 1,
+ "manifestInfo": {
+ "identifier": "TestPlugin",
+ "name": "Test Plugin",
+ "version": "1.5.0"
+ },
+ "buildInfo": {}
+ }]
+ """);
+ }
+
+ if (path.Contains("/versions/1.5.0", StringComparison.Ordinal) && !path.EndsWith("/download", StringComparison.Ordinal))
+ {
+ return TestHttpMessageHandler.JsonResponse("""
+ {
+ "projectSlug": "test-plugin",
+ "buildId": 1,
+ "manifestInfo": {
+ "identifier": "OtherPlugin",
+ "name": "Other Plugin",
+ "version": "1.5.0"
+ },
+ "buildInfo": {}
+ }
+ """);
+ }
+
+ return new HttpResponseMessage(HttpStatusCode.NotFound);
+ }));
+ httpClient.BaseAddress = new Uri("https://plugins.example/");
+ var pluginDir = Path.Combine(Path.GetTempPath(), $"btcpay-plugin-test-{Guid.NewGuid():N}");
+ try
+ {
+ var pluginService = new PluginService(
+ [],
+ new PluginBuilderClient(httpClient),
+ Options.Create(new DataDirectories { PluginDir = pluginDir }),
+ new PoliciesSettings(),
+ new BTCPayServerEnvironment(null, CreateNetworkProvider(ChainName.Regtest), null, new BTCPayServerOptions()));
+
+ var ex = await Assert.ThrowsAsync<InvalidDataException>(() => pluginService.DownloadRemotePlugin("TestPlugin", "1.5.0"));
+
+ Assert.Contains("does not match requested plugin", ex.Message);
+ }
+ finally
+ {
+ if (Directory.Exists(pluginDir))
+ Directory.Delete(pluginDir, true);
+ }
+ }
+
+ [Fact]
+ public async Task InstalledPluginsViewModel_DropsUnsafeMetadataLinks()
+ {
+ var availablePlugin = MakeAvailablePlugin("TestPlugin", "1.0.0");
+ availablePlugin.Author = "Author";
+ availablePlugin.AuthorLink = "javascript:alert(1)";
+ availablePlugin.Source = "https://github.com/btcpayserver/test-plugin";
+ availablePlugin.Documentation = "/relative-docs";
+
+ var model = await CreateInstalledPluginsViewModel(
+ loadedPlugins: [MakeLoadedPlugin("TestPlugin")],
+ allAvailable: [availablePlugin]);
+
+ var plugin = Assert.Single(model.InstalledPlugins).Current;
+ Assert.Equal("Author", plugin.Author);
+ Assert.Null(plugin.AuthorLink);
+ Assert.Equal("https://github.com/btcpayserver/test-plugin", plugin.Source);
+ Assert.Null(plugin.Documentation);
+ }
+
+ [Fact]
+ public void PluginDirectoryViewModel_IgnoresSelectionWhenSlugIsNotInCatalog()
+ {
+ var model = CreatePluginDirectoryViewModel(selectedSlug: "unlisted-plugin");
+
+ Assert.Null(model.SelectedPluginPanel.SelectedSlug);
+ Assert.Null(model.SelectedPluginPanel.PluginIdentifier);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public async Task SelectedPluginPanel_ReturnsBadRequestWhenSlugIsMissing(string slug)
+ {
+ using var httpClient = new HttpClient(new TestHttpMessageHandler(_ =>
+ TestHttpMessageHandler.JsonResponse("[]")))
+ {
+ BaseAddress = new Uri("https://plugins.example/")
+ };
+ var controller = CreatePluginManagerController(Path.GetTempPath(), [], httpClient);
+
+ var result = await controller.SelectedPluginPanel(slug);
+
+ Assert.IsType<Microsoft.AspNetCore.Mvc.BadRequestResult>(result);
+ }
+
+ [Fact]
+ public async Task SelectedPluginPanel_ReturnsServiceUnavailableWhenCatalogLookupFails()
+ {
+ using var httpClient = new HttpClient(new TestHttpMessageHandler(_ =>
+ new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)))
+ {
+ BaseAddress = new Uri("https://plugins.example/")
+ };
+ var controller = CreatePluginManagerController(Path.GetTempPath(), [], httpClient);
+
+ var result = await controller.SelectedPluginPanel("testplugin");
+
+ var statusCode = Assert.IsType<Microsoft.AspNetCore.Mvc.StatusCodeResult>(result);
+ Assert.Equal((int)HttpStatusCode.ServiceUnavailable, statusCode.StatusCode);
+ }
+
+ [Fact]
+ public void PluginDirectoryViewModel_HidesInstalledAndDisabledIdentifiersForEmbed()
+ {
+ var model = CreatePluginDirectoryViewModel(
+ disabled: new Dictionary<string, Version> { { "DisabledPlugin", new Version(1, 0, 0) } },
+ loadedPlugins: [MakeLoadedPlugin("InstalledPlugin")]);
+
+ Assert.Equal(["DisabledPlugin", "InstalledPlugin"], model.HiddenPluginIdentifiers);
+ }
+
+ [Fact]
+ public void PluginDirectoryViewModel_DoesNotAllowInstallForInstalledPlugin()
+ {
+ var model = CreatePluginDirectoryViewModel(
+ selectedSlug: "installedplugin",
+ loadedPlugins: [MakeLoadedPlugin("InstalledPlugin")],
+ allAvailable: [MakeAvailablePlugin("InstalledPlugin", "1.1.0")]);
+
+ Assert.Equal("InstalledPlugin", model.SelectedPluginPanel.PluginIdentifier);
+ Assert.Equal("installedplugin", model.SelectedPluginPanel.SelectedSlug);
+ Assert.Null(model.SelectedPluginPanel.InstallVersion);
+ }
+
+ [Fact]
+ public void PluginDirectoryViewModel_DoesNotAllowInstallForDisabledPlugin()
+ {
+ var model = CreatePluginDirectoryViewModel(
+ selectedSlug: "disabledplugin",
+ disabled: new Dictionary<string, Version> { { "DisabledPlugin", new Version(1, 0, 0) } },
+ allAvailable: [MakeAvailablePlugin("DisabledPlugin", "1.1.0")]);
+
+ Assert.Equal("DisabledPlugin", model.SelectedPluginPanel.PluginIdentifier);
+ Assert.Null(model.SelectedPluginPanel.InstallVersion);
+ }
+
+ [Fact]
+ public void PluginDirectoryViewModel_ExposesPendingAction()
+ {
+ var pendingManifest = MakeAvailablePlugin("TestPlugin", "0.9.0");
+ var model = CreatePluginDirectoryViewModel(
+ selectedSlug: "testplugin",
+ allAvailable: [MakeAvailablePlugin("TestPlugin", "1.0.0")],
+ command: ("install", "TestPlugin"),
+ pendingManifest: pendingManifest);
+
+ Assert.True(model.HasPendingActions);
+ Assert.Equal("install", model.SelectedPluginPanel.PendingAction);
+ Assert.Null(model.SelectedPluginPanel.InstallVersion);
+ Assert.Equal(new Version(0, 9, 0), model.SelectedPluginPanel.PendingVersion);
+ }
+
+ [Fact]
+ public void PluginService_LoadsPendingInstallAndEnableManifests()
+ {
+ var pluginDir = Path.Combine(Path.GetTempPath(), $"btcpay-plugin-manifest-test-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(pluginDir);
+ try
+ {
+ var installManifest = MakeAvailablePlugin("InstallPlugin", "1.2.0", ("InstallDependency", ">=1.0.0"));
+ File.WriteAllText(
+ Path.Combine(pluginDir, "InstallPlugin.json"),
+ JsonConvert.SerializeObject(installManifest));
+
+ var enablePluginDir = Path.Combine(pluginDir, "EnablePlugin");
+ Directory.CreateDirectory(enablePluginDir);
+ var enableManifest = MakeAvailablePlugin("EnablePlugin", "2.3.0", ("EnableDependency", ">=2.0.0"));
+ File.WriteAllText(
+ Path.Combine(enablePluginDir, "EnablePlugin.json"),
+ JsonConvert.SerializeObject(enableManifest));
+
+ using var httpClient = new HttpClient { BaseAddress = new Uri("https://plugins.example/") };
+ var pluginService = new PluginService(
+ [],
+ new PluginBuilderClient(httpClient),
+ Options.Create(new DataDirectories { PluginDir = pluginDir }),
+ new PoliciesSettings(),
+ new BTCPayServerEnvironment(null, CreateNetworkProvider(ChainName.Regtest), null, new BTCPayServerOptions()));
+
+ var pendingInstall = pluginService.GetPendingPluginManifest("install", "InstallPlugin");
+ Assert.Equal(new Version(1, 2, 0), pendingInstall.Version);
+ Assert.Equal("InstallDependency", Assert.Single(pendingInstall.Dependencies).Identifier);
+
+ var pendingEnable = pluginService.GetPendingPluginManifest("enable", "EnablePlugin");
+ Assert.Equal(new Version(2, 3, 0), pendingEnable.Version);
+ Assert.Equal("EnableDependency", Assert.Single(pendingEnable.Dependencies).Identifier);
+ }
+ finally
+ {
+ Directory.Delete(pluginDir, true);
+ }
+ }
+
+ [Fact]
+ public async Task InstalledPluginsViewModel_BlocksUninstallWhenPendingInstallDependsOnInstalledPlugin()
+ {
+ var model = await CreateInstalledPluginsViewModel(
+ loadedPlugins: [MakeLoadedPlugin("Dependency")],
+ allAvailable: [MakeAvailablePlugin("Dependent", "1.0.0", ("Dependency", ">=1.0.0"))],
+ command: ("install", "Dependent"));
+
+ var plugin = Assert.Single(model.InstalledPlugins);
+ var blockedAction = Assert.Single(plugin.Actions);
+ Assert.Null(blockedAction.FormAction);
+ Assert.NotNull(blockedAction.Tooltip);
+ }
+
+ [Fact]
+ public async Task InstalledPluginsViewModel_UsesPendingInstallManifestForUninstallProtection()
+ {
+ var pendingManifest = MakeAvailablePlugin("Dependent", "1.0.0", ("Dependency", ">=1.0.0"));
+ var model = await CreateInstalledPluginsViewModel(
+ loadedPlugins: [MakeLoadedPlugin("Dependency")],
+ allAvailable: [MakeAvailablePlugin("Dependent", "2.0.0")],
+ command: ("install", "Dependent"),
+ pendingManifest: pendingManifest);
+
+ var plugin = Assert.Single(model.InstalledPlugins);
+ var blockedAction = Assert.Single(plugin.Actions);
+ Assert.Null(blockedAction.FormAction);
+ }
+
+ [Fact]
+ public async Task InstalledPluginsViewModel_UsesPendingEnableManifestInsteadOfLatestRemote()
+ {
+ var pendingManifest = MakeAvailablePlugin("Dependent", "1.0.0");
+ var model = await CreateInstalledPluginsViewModel(
+ loadedPlugins: [MakeLoadedPlugin("Dependency")],
+ allAvailable: [MakeAvailablePlugin("Dependent", "2.0.0", ("Dependency", ">=1.0.0"))],
+ command: ("enable", "Dependent"),
+ pendingManifest: pendingManifest);
+
+ var plugin = Assert.Single(model.InstalledPlugins);
+ Assert.Single(plugin.Actions, action => action.FormAction == "UnInstallPlugin");
+ }
+
+ [Fact]
+ public async Task InstalledPluginsViewModel_DoesNotBlockUninstallWhenDependentPluginIsPendingDelete()
+ {
+ var model = await CreateInstalledPluginsViewModel(
+ loadedPlugins: [
+ MakeLoadedPlugin("Dependency"),
+ MakeLoadedPlugin("Dependent", ("Dependency", ">=1.0.0"))
+ ],
+ command: ("delete", "Dependent"));
+
+ var plugin = Assert.Single(model.InstalledPlugins, plugin => plugin.Current.Identifier == "Dependency");
+ Assert.Single(plugin.Actions, action => action.FormAction == "UnInstallPlugin");
+ Assert.DoesNotContain(plugin.Actions, action => action.FormAction is null);
+ }
+
+ [Fact]
+ public void PluginDirectoryIframeUrl_IncludesCompatibilityQuery()
+ {
+ var url = UIPluginManagerController.BuildDirectoryIframeUrl(
+ new Uri("https://plugins.example.com/catalog?tenant=one#section"),
+ "2.3.7",
+ true);
+
+ Assert.Equal(
+ "https://plugins.example.com/catalog/public/plugins?embed=1&btcpayVersion=2.3.7&includePreRelease=true",
+ url);
+ }
+
+ [Fact]
+ public async Task PluginBuilderClientConfiguration_PreservesPluginSourceSubpath()
+ {
+ Uri requestedUri = null;
+ using var httpClient = new HttpClient(new TestHttpMessageHandler(request =>
+ {
+ requestedUri = request.RequestUri;
+ return TestHttpMessageHandler.JsonResponse("[]");
+ }));
+ PluginManagerPlugin.ConfigurePluginBuilderClient(
+ new PoliciesSettings { PluginSource = "https://plugins.example.com/catalog?tenant=one#section" },
+ httpClient);
+
+ await new PluginBuilderClient(httpClient).GetPublishedVersions("2.3.7", false);
+
+ Assert.Equal("https://plugins.example.com/catalog/", httpClient.BaseAddress.AbsoluteUri);
+ Assert.Equal("/catalog/api/v1/plugins", requestedUri.AbsolutePath);
+ Assert.Contains("btcpayVersion=2.3.7", requestedUri.Query);
+ Assert.DoesNotContain("tenant=one", requestedUri.Query);
+ Assert.Empty(requestedUri.Fragment);
+ }
+
+ private static PluginService.AvailablePlugin MakeAvailablePlugin(
+ string identifier, string version, params (string id, string condition)[] dependencies)
+ {
+ return new PluginService.AvailablePlugin
+ {
+ Identifier = identifier,
+ CatalogSlug = identifier.ToLowerInvariant(),
+ Name = identifier,
+ Version = Version.Parse(version),
+ Dependencies = dependencies.Select(d => new IBTCPayServerPlugin.PluginDependency
+ {
+ Identifier = d.id,
+ Condition = d.condition
+ }).ToArray()
+ };
+ }
+
+ private static IBTCPayServerPlugin MakeLoadedPlugin(
+ string identifier,
+ params (string id, string condition)[] dependencies)
+ {
+ return new TestPlugin(
+ identifier,
+ dependencies.Select(d => new IBTCPayServerPlugin.PluginDependency
+ {
+ Identifier = d.id,
+ Condition = d.condition
+ }).ToArray());
+ }
+
+ private async Task<InstalledPluginsViewModel> CreateInstalledPluginsViewModel(
+ Dictionary<string, Version> disabled = null,
+ IEnumerable<PluginService.AvailablePlugin> allAvailable = null,
+ IEnumerable<IBTCPayServerPlugin> loadedPlugins = null,
+ (string action, string plugin)? command = null,
+ PluginService.AvailablePlugin pendingManifest = null)
+ {
+ var loaded = loadedPlugins?.ToArray() ?? [];
+ var pluginDir = Path.Combine(Path.GetTempPath(), $"btcpay-plugin-projection-test-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(pluginDir);
+ try
+ {
+ WritePluginState(pluginDir, disabled, command, pendingManifest);
+ using var httpClient = new HttpClient { BaseAddress = new Uri("https://plugins.example/") };
+ var controller = CreatePluginManagerController(pluginDir, loaded, httpClient);
+ return await controller.CreateInstalledPluginsViewModel(allAvailable ?? []);
+ }
+ finally
+ {
+ Directory.Delete(pluginDir, true);
+ }
+ }
+
+ private PluginDirectoryViewModel CreatePluginDirectoryViewModel(
+ Dictionary<string, Version> disabled = null,
+ IEnumerable<PluginService.AvailablePlugin> allAvailable = null,
+ string selectedSlug = null,
+ IEnumerable<IBTCPayServerPlugin> loadedPlugins = null,
+ (string action, string plugin)? command = null,
+ PluginService.AvailablePlugin pendingManifest = null)
+ {
+ var loaded = loadedPlugins?.ToArray() ?? [];
+ var pluginDir = Path.Combine(Path.GetTempPath(), $"btcpay-plugin-directory-projection-test-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(pluginDir);
+ try
+ {
+ WritePluginState(pluginDir, disabled, command, pendingManifest);
+ using var httpClient = new HttpClient { BaseAddress = new Uri("https://plugins.example/") };
+ var controller = CreatePluginManagerController(pluginDir, loaded, httpClient);
+ return controller.CreatePluginDirectoryViewModel(selectedSlug, allAvailable?.ToArray() ?? []);
+ }
+ finally
+ {
+ Directory.Delete(pluginDir, true);
+ }
+ }
+
+ private UIPluginManagerController CreatePluginManagerController(
+ string pluginDir,
+ IEnumerable<IBTCPayServerPlugin> loadedPlugins,
+ HttpClient httpClient)
+ {
+ var policiesSettings = new PoliciesSettings();
+ var pluginService = new PluginService(
+ loadedPlugins,
+ new PluginBuilderClient(httpClient),
+ Options.Create(new DataDirectories { PluginDir = pluginDir }),
+ policiesSettings,
+ new BTCPayServerEnvironment(null, CreateNetworkProvider(ChainName.Regtest), null, new BTCPayServerOptions()));
+ return new UIPluginManagerController(pluginService, policiesSettings, null);
+ }
+
+ private static void WritePluginState(
+ string pluginDir,
+ Dictionary<string, Version> disabled,
+ (string action, string plugin)? command,
+ PluginService.AvailablePlugin pendingManifest)
+ {
+ if (disabled is { Count: > 0 })
+ {
+ File.WriteAllLines(Path.Combine(pluginDir, "disabled"), disabled.Keys);
+ foreach (var (identifier, version) in disabled)
+ {
+ var disabledPluginDir = Path.Combine(pluginDir, identifier);
+ Directory.CreateDirectory(disabledPluginDir);
+ if (version is null)
+ continue;
+
+ var manifest = MakeAvailablePlugin(identifier, version.ToString());
+ File.WriteAllText(
+ Path.Combine(disabledPluginDir, identifier + ".json"),
+ JsonConvert.SerializeObject(manifest));
+ }
+ }
+
+ if (command is not { } pendingCommand)
+ return;
+
+ var (action, plugin) = pendingCommand;
+ File.WriteAllText(Path.Combine(pluginDir, "commands"), $"{action}:{plugin}");
+ var manifestPath = action switch
+ {
+ "install" => Path.Combine(pluginDir, plugin + ".json"),
+ "enable" => Path.Combine(pluginDir, plugin, plugin + ".json"),
+ _ => null
+ };
+ if (pendingManifest is null || manifestPath is null)
+ return;
+
+ Directory.CreateDirectory(Path.GetDirectoryName(manifestPath)!);
+ File.WriteAllText(manifestPath, JsonConvert.SerializeObject(pendingManifest));
+ }
+
+ private sealed class TestPlugin(
+ string identifier,
+ IBTCPayServerPlugin.PluginDependency[] dependencies) : BaseBTCPayServerPlugin
+ {
+ public override string Identifier => identifier;
+ public override string Name => identifier;
+ public override Version Version => new(1, 0, 0);
+ public override string Description => identifier;
+ public override IBTCPayServerPlugin.PluginDependency[] Dependencies => dependencies;
+ }
+ }
+}
diff --git a/BTCPayServer.Tests/TestUtils.cs b/BTCPayServer.Tests/TestUtils.cs
index 1f2cbf8..1a55c54 100644
--- a/BTCPayServer.Tests/TestUtils.cs
+++ b/BTCPayServer.Tests/TestUtils.cs
@@ -1,7 +1,10 @@
using System;
+using System.Collections.Generic;
using System.IO;
using System.Linq;
+using System.Net;
using System.Net.Http;
+using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
@@ -132,4 +135,31 @@ namespace BTCPayServer.Tests
return services.BuildServiceProvider().GetRequiredService<IHttpClientFactory>();
}
}
+
+ internal sealed class TestHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> handle = null) : HttpMessageHandler
+ {
+ private readonly Dictionary<string, Func<HttpResponseMessage>> _responses = new();
+
+ public void Register(string url, Func<HttpResponseMessage> responseFactory) => _responses[url] = responseFactory;
+
+ public static HttpResponseMessage JsonResponse(string json)
+ {
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(json, Encoding.UTF8, "application/json")
+ };
+ }
+
+ protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ if (handle is not null)
+ return Task.FromResult(handle(request));
+
+ var url = request.RequestUri!.ToString();
+ if (_responses.TryGetValue(url, out var responseFactory))
+ return Task.FromResult(responseFactory());
+
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
+ }
+ }
}
diff --git a/BTCPayServer/Components/GlobalNav/Default.cshtml b/BTCPayServer/Components/GlobalNav/Default.cshtml
index 3d0645d..8b0b3a5 100644
--- a/BTCPayServer/Components/GlobalNav/Default.cshtml
+++ b/BTCPayServer/Components/GlobalNav/Default.cshtml
@@ -2,6 +2,7 @@
@using BTCPayServer.Views.Manage
@using BTCPayServer.Client
@using BTCPayServer.Plugins
+@using BTCPayServer.Plugins.PluginManagement
@using BTCPayServer.Services
@using BTCPayServer.Plugins.Emails
@using BTCPayServer.Plugins.Translations
@@ -12,7 +13,6 @@
@model BTCPayServer.Components.GlobalNav.GlobalNavViewModel
@{
- var pluginsUrl = Url.Action("ListPlugins", "UIServer");
var loginName = User.Identity?.Name;
var activePage = ViewData["ActivePage"]?.ToString();
var serverEmailsPage = $"Server-{nameof(ServerNavPages.Emails)}";
@@ -47,18 +47,12 @@
}
</button>
<ul id="globalNavPluginsMenu" class="dropdown-menu dropdown-menu-end">
- @if (!string.IsNullOrEmpty(pluginsUrl))
- {
- <li>
- <a id="menu-item-@nameof(ServerNavPages.Plugins)" class="dropdown-item@(activePage == nameof(ServerNavPages.Plugins) ? " active" : "")" asp-area="" asp-controller="UIServer" asp-action="ListPlugins" text-translate="true">Manage Plugins</a>
- </li>
- <li>
- <a class="dropdown-item" asp-area="" asp-controller="UIServer" asp-action="ListPlugins" asp-fragment="plugins-installed" text-translate="true">Installed Plugins</a>
- </li>
- <li>
- <a class="dropdown-item" asp-area="" asp-controller="UIServer" asp-action="ListPlugins" asp-fragment="plugins-directory" text-translate="true">Plugin Directory</a>
- </li>
- }
+ <li>
+ <a class="dropdown-item" asp-area="@PluginManagerPlugin.Area" asp-controller="UIPluginManager" asp-action="ListPlugins" text-translate="true">Installed Plugins</a>
+ </li>
+ <li>
+ <a class="dropdown-item" asp-area="@PluginManagerPlugin.Area" asp-controller="UIPluginManager" asp-action="PluginDirectory" text-translate="true">Plugin Directory</a>
+ </li>
</ul>
</div>
diff --git a/BTCPayServer/Configuration/BTCPayServerOptions.cs b/BTCPayServer/Configuration/BTCPayServerOptions.cs
index 580b0bc..df2586f 100644
--- a/BTCPayServer/Configuration/BTCPayServerOptions.cs
+++ b/BTCPayServer/Configuration/BTCPayServerOptions.cs
@@ -1,6 +1,5 @@
using System;
using System.IO;
-using System.Linq;
using System.Net;
using BTCPayServer.Logging;
using BTCPayServer.SSH;
@@ -154,13 +153,11 @@ namespace BTCPayServer.Configuration
}
DisableRegistration = conf.GetOrDefault<bool>("disable-registration", true);
- RecommendedPlugins = conf.GetOrDefault("recommended-plugins", "").ToLowerInvariant().Split('\r', '\n', '\t', ' ').Where(s => !string.IsNullOrEmpty(s)).Distinct().ToArray();
CheatMode = conf.GetOrDefault("cheatmode", false);
if (CheatMode && this.NetworkType == ChainName.Mainnet)
throw new ConfigException($"cheatmode can't be used on mainnet");
}
- public string[] RecommendedPlugins { get; set; }
public bool CheatMode { get; set; }
private SSHSettings ParseSSHConfiguration(IConfiguration conf)
diff --git a/BTCPayServer/Configuration/DefaultConfiguration.cs b/BTCPayServer/Configuration/DefaultConfiguration.cs
index 7de4339..5fe9ebc 100644
--- a/BTCPayServer/Configuration/DefaultConfiguration.cs
+++ b/BTCPayServer/Configuration/DefaultConfiguration.cs
@@ -52,7 +52,7 @@ namespace BTCPayServer.Configuration
app.Option("--debuglog", "A rolling log file for debug messages.", CommandOptionType.SingleValue);
app.Option("--debugloglevel", "The severity you log (default:information)", CommandOptionType.SingleValue);
app.Option("--disable-registration", "Disables new user registrations (default:true)", CommandOptionType.SingleValue);
- app.Option("--recommended-plugins", "Plugins which would be marked as recommended to be installed. Separated by newline or space", CommandOptionType.MultipleValue);
+ app.Option("--recommended-plugins", "Deprecated. Accepted for compatibility but no longer used by the embedded plugin directory.", CommandOptionType.MultipleValue);
app.Option("--xforwardedproto", "If specified, set X-Forwarded-Proto to the specified value, this may be useful if your reverse proxy handle https but is not configured to add X-Forwarded-Proto (example: --xforwardedproto https)", CommandOptionType.SingleValue);
app.Option("--cheatmode", "Add some helper UI to facilitate dev-time testing (Default false)", CommandOptionType.BoolValue);
diff --git a/BTCPayServer/Controllers/UIServerController.Plugins.cs b/BTCPayServer/Controllers/UIServerController.Plugins.cs
deleted file mode 100644
index bd8490b..0000000
--- a/BTCPayServer/Controllers/UIServerController.Plugins.cs
+++ /dev/null
@@ -1,252 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Threading.Tasks;
-using BTCPayServer.Abstractions.Contracts;
-using BTCPayServer.Abstractions.Extensions;
-using BTCPayServer.Abstractions.Models;
-using BTCPayServer.Configuration;
-using BTCPayServer.Plugins;
-using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.Mvc;
-using static BTCPayServer.Plugins.PluginService;
-
-namespace BTCPayServer.Controllers
-{
- public partial class UIServerController
- {
- [HttpGet("server/plugins")]
- public async Task<IActionResult> ListPlugins(
- [FromServices] PluginService pluginService,
- [FromServices] BTCPayServerOptions btcPayServerOptions,
- string search = null)
- {
- IEnumerable<PluginService.AvailablePlugin> availablePlugins;
- IEnumerable<PluginService.AvailablePlugin> allPlugins;
- try
- {
- allPlugins = await pluginService.GetRemotePlugins(null);
- availablePlugins = string.IsNullOrEmpty(search)
- ? allPlugins
- : allPlugins.Where(p =>
- p.Identifier.Contains(search, StringComparison.OrdinalIgnoreCase) ||
- p.Name.Contains(search, StringComparison.OrdinalIgnoreCase));
- }
- catch (Exception ex)
- {
- TempData.SetStatusMessageModel(new StatusMessageModel
- {
- Severity = StatusMessageModel.StatusSeverity.Error,
- Message = StringLocalizer["Remote plugins lookup failed. Try again later. Error: {0}", ex.Message].Value
- });
- availablePlugins = Array.Empty<PluginService.AvailablePlugin>();
- allPlugins = [];
- }
- var availablePluginsByIdentifier = new Dictionary<string, AvailablePlugin>(StringComparer.OrdinalIgnoreCase);
- foreach (var p in allPlugins)
- {
- if (!availablePluginsByIdentifier.TryGetValue(p.Identifier, out var existing) || p.Version > existing.Version)
- availablePluginsByIdentifier[p.Identifier] = p;
- }
-
- var disabled = pluginService.GetDisabledPlugins();
- var installed = pluginService.Installed;
- var disabledPluginUpdates = ListPluginsViewModel.GetDisabledPluginUpdates(disabled, availablePluginsByIdentifier);
- var res = new ListPluginsViewModel()
- {
- Plugins = pluginService.LoadedPlugins,
- Installed = installed,
- Available = availablePlugins,
- Commands = pluginService.GetPendingCommands(),
- Disabled = disabled,
- CanShowRestart = true,
- DownloadedPluginsByIdentifier = availablePluginsByIdentifier,
- DisabledPluginUpdates = disabledPluginUpdates
- };
- return View(res);
- }
-
- public class ListPluginsViewModel
- {
- public IEnumerable<IBTCPayServerPlugin> Plugins { get; set; }
- public IEnumerable<PluginService.AvailablePlugin> Available { get; set; }
- public (string command, string plugin)[] Commands { get; set; }
- public bool CanShowRestart { get; set; }
- public Dictionary<string, Version> Disabled { get; set; }
- public Dictionary<string, AvailablePlugin> DownloadedPluginsByIdentifier { get; set; } = new Dictionary<string, AvailablePlugin>();
- public Dictionary<string, Version> Installed { get; set; }
- public Dictionary<string, PluginService.AvailablePlugin> DisabledPluginUpdates { get; set; }
-
- public static Dictionary<string, PluginService.AvailablePlugin> GetDisabledPluginUpdates(
- Dictionary<string, Version> disabled,
- Dictionary<string, AvailablePlugin> availablePluginsByIdentifier)
- {
- var result = new Dictionary<string, PluginService.AvailablePlugin>();
- foreach (var (disabledPlugin, disabledVersion) in disabled)
- {
- if (disabledVersion == null) continue;
- if (availablePluginsByIdentifier.TryGetValue(disabledPlugin, out var available))
- {
- if (available.Version > disabledVersion)
- result[disabledPlugin] = available;
- }
- }
- return result;
- }
- }
-
- [HttpPost("server/plugins/uninstall-all")]
- public IActionResult UnInstallAllDisabledPlugin(
- [FromServices] PluginService pluginService, string plugin)
- {
- var disabled = pluginService.GetDisabledPlugins();
- foreach (var d in disabled)
- pluginService.UninstallPlugin(d.Key);
- return RedirectToAction(nameof(ListPlugins));
- }
-
- [HttpPost("server/plugins/uninstall")]
- public IActionResult UnInstallPlugin(
- [FromServices] PluginService pluginService, string plugin)
- {
- pluginService.UninstallPlugin(plugin);
- TempData.SetStatusMessageModel(new StatusMessageModel
- {
- Message = StringLocalizer["Plugin scheduled to be uninstalled."].Value,
- Severity = StatusMessageModel.StatusSeverity.Success
- });
-
- return RedirectToAction("ListPlugins");
- }
-
- [HttpPost("server/plugins/enable")]
- public IActionResult EnablePlugin(
- [FromServices] PluginService pluginService, string plugin)
- {
- pluginService.EnablePlugin(plugin);
- TempData.SetStatusMessageModel(new StatusMessageModel
- {
- Message = StringLocalizer["Plugin scheduled to be enabled."].Value,
- 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 = StringLocalizer["Plugin action cancelled."].Value,
- Severity = StatusMessageModel.StatusSeverity.Success
- });
-
- return RedirectToAction("ListPlugins");
- }
-
- [HttpPost("server/plugins/install")]
- public async Task<IActionResult> InstallPlugin(
- [FromServices] PluginService pluginService, string plugin, bool update = false, string version = null)
- {
- var ctx = new DownloadPluginContext(pluginService, plugin, version, new(), new(), null);
- await DownloadPluginAndDependencies(ctx);
- if (ctx.DependencyFailed.Count == 0)
- {
- TempData.SetStatusMessageModel(new StatusMessageModel
- {
- Message = StringLocalizer["Plugin scheduled to be installed."].Value,
- Severity = StatusMessageModel.StatusSeverity.Success
- });
- }
- else
- {
- var error = String.Join(" \n", ctx.DependencyFailed
- .Select(d => $"{d.Key}: {d.Value}")
- .ToArray());
- TempData.SetStatusMessageModel(new StatusMessageModel
- {
- Message = StringLocalizer["The plugin could not be downloaded. Try again later."].Value + " \n" + error,
- Severity = StatusMessageModel.StatusSeverity.Error
- });
- }
-
- return RedirectToAction("ListPlugins");
- }
-
- public record DownloadPluginContext(PluginService PluginService, string Plugin, string Version, Dictionary<string, AvailablePlugin> Downloaded, Dictionary<string, string> DependencyFailed, VersionCondition VersionCondition);
- private async Task DownloadPluginAndDependencies(DownloadPluginContext ctx)
- {
- if (ctx.Downloaded.ContainsKey(ctx.Plugin)
- ||
- ctx.DependencyFailed.ContainsKey(ctx.Plugin))
- return;
- AvailablePlugin manifest;
- try
- {
- manifest = await ctx.PluginService.DownloadRemotePlugin(ctx.Plugin, ctx.Version, ctx.VersionCondition);
- }
- catch(Exception ex)
- {
- ctx.DependencyFailed.Add(ctx.Plugin, ex.Message);
- return;
- }
-
- foreach (var dep in manifest.Dependencies)
- {
- if (!PluginManager.DependencyMet(dep, ctx.PluginService.Installed))
- {
- if (dep.Identifier.Equals("BTCPayServer", StringComparison.OrdinalIgnoreCase))
- {
- ctx.DependencyFailed.Add(ctx.Plugin, $"This condition can't be satisfied {dep}");
- return;
- }
-
- var cond = dep.ParseCondition();
- var childCtx = ctx with
- {
- Plugin = dep.Identifier,
- Version = null,
- VersionCondition = cond
- };
- if (childCtx.VersionCondition is VersionCondition.Not)
- {
- ctx.DependencyFailed.Add(ctx.Plugin, $"The currently installed plugin {dep.Identifier} is incompatible with this plugin.");
- return;
- }
-
- await DownloadPluginAndDependencies(childCtx);
- if (childCtx.DependencyFailed.ContainsKey(childCtx.Plugin))
- {
- ctx.DependencyFailed.Add(ctx.Plugin, $"Failed to download dependency {dep.Identifier}");
- return;
- }
- }
- }
-
- ctx.PluginService.InstallPlugin(ctx.Plugin);
- ctx.Downloaded.Add(ctx.Plugin, manifest);
- }
-
- [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).Where(file => file.FileName.IsValidFileName()))
- {
- await pluginService.UploadPlugin(formFile);
- pluginService.InstallPlugin(formFile.FileName.TrimEnd(PluginManager.BTCPayPluginSuffix,
- StringComparison.InvariantCultureIgnoreCase));
- }
-
- TempData.SetStatusMessageModel(new StatusMessageModel
- {
- Message = StringLocalizer["Files uploaded, restart server to load plugins"].Value,
- Severity = StatusMessageModel.StatusSeverity.Success
- });
- return RedirectToAction("ListPlugins");
- }
- }
-}
diff --git a/BTCPayServer/HostedServices/PluginUpdateFetcher.cs b/BTCPayServer/HostedServices/PluginUpdateFetcher.cs
deleted file mode 100644
index 033f5d4..0000000
--- a/BTCPayServer/HostedServices/PluginUpdateFetcher.cs
+++ /dev/null
@@ -1,115 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using BTCPayServer.Abstractions.Contracts;
-using BTCPayServer.Configuration;
-using BTCPayServer.Controllers;
-using BTCPayServer.Plugins;
-using BTCPayServer.Services;
-using BTCPayServer.Services.Notifications;
-using Microsoft.AspNetCore.Routing;
-using Microsoft.Extensions.Localization;
-
-namespace BTCPayServer.HostedServices
-{
- internal class PluginUpdateNotification : BaseNotification
- {
- private const string TYPE = "pluginupdate";
-
- internal class Handler(LinkGenerator linkGenerator, BTCPayServerOptions options, IStringLocalizer stringLocalizer) : NotificationHandler<PluginUpdateNotification>
- {
- private IStringLocalizer StringLocalizer { get; } = stringLocalizer;
-
- public override string NotificationType => TYPE;
-
- public override (string identifier, string name)[] Meta
- {
- get
- {
- return new (string identifier, string name)[] {(TYPE, StringLocalizer["Plugin update"])};
- }
- }
-
- protected override void FillViewModel(PluginUpdateNotification notification, NotificationViewModel vm)
- {
- vm.Identifier = notification.Identifier;
- vm.Type = notification.NotificationType;
- vm.Body = StringLocalizer["New {0} plugin version {1} released!", notification.Name, notification.Version];
- vm.ActionLink = linkGenerator.GetPathByAction(nameof(UIServerController.ListPlugins),
- "UIServer",
- new {plugin = notification.PluginIdentifier}, options.RootPath);
- }
- }
-
- public PluginUpdateNotification()
- {
- }
-
- public PluginUpdateNotification(PluginService.AvailablePlugin plugin)
- {
- Name = plugin.Name;
- PluginIdentifier = plugin.Identifier;
- Version = plugin.Version.ToString();
- }
-
- public string PluginIdentifier { get; set; }
-
- public string Name { get; set; }
-
- public string Version { get; set; }
- public override string Identifier => TYPE;
- public override string NotificationType => TYPE;
- }
-
- public class PluginVersionCheckerDataHolder
- {
- public Dictionary<string, Version> LastVersions { get; set; }
- }
-
- public class PluginUpdateFetcher(SettingsRepository settingsRepository, NotificationSender notificationSender, PluginService pluginService)
- : IPeriodicTask
- {
- public async Task Do(CancellationToken cancellationToken)
- {
- var dh = await settingsRepository.GetSettingAsync<PluginVersionCheckerDataHolder>() ??
- new PluginVersionCheckerDataHolder();
- dh.LastVersions ??= new Dictionary<string, Version>();
- var disabledPlugins = pluginService.GetDisabledPlugins();
-
- var installedPlugins = pluginService.Installed;
- var remotePlugins = await pluginService.GetRemotePlugins(null, cancellationToken);
- //take the latest version of each plugin
- var remotePluginsList = remotePlugins
- .GroupBy(plugin => plugin.Identifier)
- .Select(group => group.OrderByDescending(plugin => plugin.Version).First())
- .Where(pair => installedPlugins.ContainsKey(pair.Identifier) || disabledPlugins.ContainsKey(pair.Name))
- .ToDictionary(plugin => plugin.Identifier, plugin => plugin.Version);
- var notify = new HashSet<string>();
- foreach (var pair in remotePluginsList)
- {
- if (dh.LastVersions.TryGetValue(pair.Key, out var lastVersion) && lastVersion >= pair.Value)
- continue;
- if (installedPlugins.TryGetValue(pair.Key, out var installedVersion) && installedVersion < pair.Value)
- {
- notify.Add(pair.Key);
- }
- else if (disabledPlugins.TryGetValue(pair.Key, out var disabledVersion) && disabledVersion < pair.Value)
- {
- notify.Add(pair.Key);
- }
- }
-
- dh.LastVersions = remotePluginsList;
-
- foreach (string pluginUpdate in notify)
- {
- var plugin = remotePlugins.First(p => p.Identifier == pluginUpdate);
- await notificationSender.SendNotification(new AdminScope(), new PluginUpdateNotification(plugin));
- }
-
- await settingsRepository.UpdateSetting(dh);
- }
- }
-}
diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs
index 282c2f7..6971e06 100644
--- a/BTCPayServer/Hosting/BTCPayServerServices.cs
+++ b/BTCPayServer/Hosting/BTCPayServerServices.cs
@@ -106,16 +106,6 @@ namespace BTCPayServer.Hosting
{
httpClient.Timeout = Timeout.InfiniteTimeSpan;
});
- services.AddHttpClient<PluginBuilderClient>((prov, httpClient) =>
- {
- var p = prov.GetRequiredService<PoliciesSettings>();
- var pluginSource = p.PluginSource ?? PoliciesSettings.DefaultPluginSource;
- if (pluginSource.EndsWith('/'))
- pluginSource = pluginSource.Substring(0, pluginSource.Length - 1);
- if (!Uri.TryCreate(pluginSource, UriKind.Absolute, out var r) || (r.Scheme != "https" && r.Scheme != "http"))
- r = new Uri(PoliciesSettings.DefaultPluginSource, UriKind.Absolute);
- httpClient.BaseAddress = r;
- });
services.AddSingleton<PrettyNameProvider>();
services.AddSingleton<Logs>(logs);
@@ -297,7 +287,6 @@ namespace BTCPayServer.Hosting
services.AddExceptionHandler<PluginExceptionHandler>();
services.TryAddSingleton<AppService>();
- services.AddTransient<PluginService>();
services.AddSingleton<PluginHookService>();
services.AddSingleton<IPluginHookService, PluginHookService>(provider => provider.GetService<PluginHookService>());
services.TryAddTransient<Safe>();
@@ -381,7 +370,6 @@ namespace BTCPayServer.Hosting
services.AddSingleton<IHostedService, HostedServices.CheckConfigurationHostedService>(o => o.GetRequiredService<CheckConfigurationHostedService>());
services.AddSingleton<IHostedService, PeriodicTaskLauncherHostedService>();
services.AddScheduledTask<GithubVersionFetcher>(TimeSpan.FromDays(1));
- services.AddScheduledTask<PluginUpdateFetcher>(TimeSpan.FromDays(1));
services.AddSearchResultItemProvider<ReportingSearchResultProvider>();
services.AddReportProvider<PaymentsReportProvider>();
@@ -461,7 +449,6 @@ namespace BTCPayServer.Hosting
services.AddSingleton<INotificationHandler, NewVersionNotification.Handler>();
services.AddSingleton<INotificationHandler, NewUserRequiresApprovalNotification.Handler>();
- services.AddSingleton<INotificationHandler, PluginUpdateNotification.Handler>();
services.AddSingleton<INotificationHandler, InvoiceEventNotification.Handler>();
services.AddSingleton<INotificationHandler, PayoutNotification.Handler>();
services.AddSingleton<INotificationHandler, ExternalPayoutTransactionNotification.Handler>();
diff --git a/BTCPayServer/Plugins/GlobalSearch/GlobalSearchPlugin.cs b/BTCPayServer/Plugins/GlobalSearch/GlobalSearchPlugin.cs
index 5398086..62462c1 100644
--- a/BTCPayServer/Plugins/GlobalSearch/GlobalSearchPlugin.cs
+++ b/BTCPayServer/Plugins/GlobalSearch/GlobalSearchPlugin.cs
@@ -206,15 +206,6 @@ public class GlobalSearchPlugin : BaseBTCPayServerPlugin
Keywords = ["Server", "Settings", "Files", "Storage", "Configure"]
},
new ActionResultItemViewModel
- {
- RequiredPolicy = Policies.CanModifyServerSettings,
- Title = "Install, update, and configure plugins",
- Action = nameof(UIServerController.ListPlugins),
- Controller = "UIServer",
- Category = "Server",
- Keywords = ["Plugins", "Configure", "Update", "Install"]
- },
- new ActionResultItemViewModel
{
RequiredPolicy = Policies.CanViewProfile,
Title = "Manage your account",
diff --git a/BTCPayServer/Plugins/PluginBuilderClient.cs b/BTCPayServer/Plugins/PluginBuilderClient.cs
deleted file mode 100644
index a85ddcd..0000000
--- a/BTCPayServer/Plugins/PluginBuilderClient.cs
+++ /dev/null
@@ -1,122 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Net.Http;
-using System.Text;
-using System.Text.RegularExpressions;
-using System.Threading;
-using System.Threading.Tasks;
-using Newtonsoft.Json;
-using Newtonsoft.Json.Linq;
-
-namespace BTCPayServer.Plugins
-{
- public class PublishedVersion
- {
- public class BuildInfoClass
- {
- public string gitCommit { get; set; }
- public string pluginDir { get; set; }
- public string gitRepository { get; set; }
-#nullable enable
- static Regex GithubRepositoryRegex = new Regex("^https://(www\\.)?github\\.com/([^/]+)/([^/]+)/?");
- public record GithubRepository(string Owner, string RepositoryName)
- {
- public string? GetSourceUrl(string commit, string pluginDir)
- {
- if (commit is null)
- return null;
- return $"https://github.com/{Owner}/{RepositoryName}/tree/{commit}/{pluginDir}";
- }
- }
- public GithubRepository? GetGithubRepository()
- {
- if (gitRepository is null)
- return null;
- var match = GithubRepositoryRegex.Match(gitRepository);
- if (!match.Success)
- return null;
- return new GithubRepository(match.Groups[2].Value, match.Groups[3].Value);
- }
-#nullable restore
- [JsonExtensionData]
- public IDictionary<string, JToken> AdditionalData { get; set; }
- }
- public string ProjectSlug { get; set; }
- public long BuildId { get; set; }
- public BuildInfoClass BuildInfo { get; set; }
- public JObject ManifestInfo { get; set; }
- public string Documentation { get; set; }
- }
-
- public record InstalledPluginRequest(string Identifier, string Version);
-
- public class PluginBuilderClient
- {
- private readonly HttpClient _httpClient;
- public HttpClient HttpClient => _httpClient;
- public PluginBuilderClient(HttpClient httpClient)
- {
- _httpClient = httpClient;
- }
- static JsonSerializerSettings serializerSettings = new() { ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver() };
- public async Task<PublishedVersion[]> GetPublishedVersions(string btcpayVersion, bool includePreRelease, string searchPluginName = null, bool? includeAllVersions = null, CancellationToken cancellationToken = default)
- {
- var queryString = $"?includePreRelease={includePreRelease}";
- if (btcpayVersion is not null)
- queryString += $"&btcpayVersion={Uri.EscapeDataString(btcpayVersion)}";
- if (searchPluginName is not null)
- queryString += $"&searchPluginName={Uri.EscapeDataString(searchPluginName)}";
- if (includeAllVersions is not null)
- queryString += $"&includeAllVersions={includeAllVersions}";
- var result = await _httpClient.GetStringAsync($"api/v1/plugins{queryString}", cancellationToken);
- return JsonConvert.DeserializeObject<PublishedVersion[]>(result, serializerSettings) ?? throw new InvalidOperationException();
- }
- public async Task<PublishedVersion> GetPlugin(string pluginSlug, string version)
- {
- try
- {
- var result = await _httpClient.GetStringAsync($"api/v1/plugins/{pluginSlug}/versions/{version}");
- return JsonConvert.DeserializeObject<PublishedVersion>(result, serializerSettings);
- }
- catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
- {
- return null;
- }
- }
-
- public async Task<PublishedVersion[]> GetPluginVersionsForDownload(string identifier, string btcpayVersion, bool includePreRelease = false, bool includeAllVersions = false)
- {
- var queryString = $"?btcpayVersion={btcpayVersion}&includePreRelease={includePreRelease}&includeAllVersions={includeAllVersions}";
- var url = $"api/v1/plugins/{identifier}{queryString}";
-
- var result = await _httpClient.GetStringAsync(url);
- return JsonConvert.DeserializeObject<PublishedVersion[]>(result, serializerSettings)
- ?? throw new InvalidOperationException();
- }
-
- public async Task<PublishedVersion[]> GetInstalledPluginsUpdates(
- string btcpayVersion,
- bool includePreRelease,
- IEnumerable<InstalledPluginRequest> plugins,
- CancellationToken cancellationToken = default)
- {
- var queryString = $"?includePreRelease={includePreRelease}";
- if (!string.IsNullOrWhiteSpace(btcpayVersion))
- queryString += $"&btcpayVersion={Uri.EscapeDataString(btcpayVersion)}";
-
- var json = JsonConvert.SerializeObject(plugins, serializerSettings);
- using var content = new StringContent(json, Encoding.UTF8, "application/json");
-
- using var resp = await _httpClient.PostAsync($"api/v1/plugins/updates{queryString}", content, cancellationToken);
- resp.EnsureSuccessStatusCode();
-
- var body = await resp.Content.ReadAsStringAsync(cancellationToken);
- var result = JsonConvert.DeserializeObject<PublishedVersion[]>(body, serializerSettings);
-
- if (result is null)
- throw new JsonException("Plugin updates response deserialized to null.");
-
- return result;
- }
- }
-}
diff --git a/BTCPayServer/Plugins/PluginExceptionHandler.cs b/BTCPayServer/Plugins/PluginExceptionHandler.cs
deleted file mode 100644
index 9613765..0000000
--- a/BTCPayServer/Plugins/PluginExceptionHandler.cs
+++ /dev/null
@@ -1,58 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Diagnostics;
-using System.Threading;
-using System.Threading.Tasks;
-using BTCPayServer.Configuration;
-using BTCPayServer.Logging;
-using Microsoft.AspNetCore.Diagnostics;
-using Microsoft.AspNetCore.Http;
-using Microsoft.Extensions.Hosting;
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Options;
-
-namespace BTCPayServer.Plugins
-{
- public class PluginExceptionHandler : IExceptionHandler
- {
- readonly string _pluginDir;
- readonly IHostApplicationLifetime _applicationLifetime;
- private readonly Logs _logs;
-
- public PluginExceptionHandler(IOptions<DataDirectories> options, IHostApplicationLifetime applicationLifetime, Logs logs)
- {
- _applicationLifetime = applicationLifetime;
- _logs = logs;
- _pluginDir = options.Value.PluginDir;
- }
- public ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
- {
- if (!GetDisablePluginIfCrash(httpContext) ||
- !PluginManager.IsExceptionByPlugin(exception, out var plugin) ||
- plugin.Instance.SystemPlugin)
- return ValueTask.FromResult(false);
- _logs.Configuration.LogError(exception, $"Unhandled exception caused by plugin '{plugin.Assembly.GetName().Name}', disabling it and restarting...");
-
- if (Debugger.IsAttached)
- {
- _logs.Configuration.LogWarning("Debugger attached detected, so we didn't disable the plugin and do not restart the server");
- return ValueTask.FromResult(false);
- }
- PluginManager.DisablePlugin(_pluginDir, plugin);
- _ = Task.Delay(3000).ContinueWith((t) => _applicationLifetime.StopApplication());
- // Returning true here means we will see Error 500 error message.
- // Returning false means that the user will see a stacktrace.
- return ValueTask.FromResult(false);
- }
-
- public static bool GetDisablePluginIfCrash(HttpContext httpContext)
- {
- return httpContext.Items.TryGetValue("DisablePluginIfCrash", out object renderingDashboard) ||
- renderingDashboard is not true;
- }
- public static void SetDisablePluginIfCrash(HttpContext httpContext)
- {
- httpContext.Items.TryAdd("DisablePluginIfCrash", true);
- }
- }
-}
diff --git a/BTCPayServer/Plugins/PluginManager/Controllers/UIPluginManagerController.cs b/BTCPayServer/Plugins/PluginManager/Controllers/UIPluginManagerController.cs
new file mode 100644
index 0000000..4c47d0e
--- /dev/null
+++ b/BTCPayServer/Plugins/PluginManager/Controllers/UIPluginManagerController.cs
@@ -0,0 +1,683 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using BTCPayServer.Abstractions.Constants;
+using BTCPayServer.Abstractions.Contracts;
+using BTCPayServer.Abstractions.Extensions;
+using BTCPayServer.Abstractions.Models;
+using BTCPayServer.Client;
+using BTCPayServer.Plugins.PluginManagement.Models;
+using BTCPayServer.Services;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Localization;
+using static BTCPayServer.Plugins.PluginService;
+
+namespace BTCPayServer.Plugins.PluginManagement.Controllers;
+
+[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanModifyServerSettings)]
+[Area(PluginManagerPlugin.Area)]
+[Route("server/plugins")]
+public class UIPluginManagerController(
+ PluginService pluginService,
+ PoliciesSettings policiesSettings,
+ IStringLocalizer stringLocalizer)
+ : Controller
+{
+ [HttpGet]
+ public async Task<IActionResult> ListPlugins()
+ {
+ var model = await CreateInstalledPluginsViewModel();
+ return View(model);
+ }
+
+ [HttpGet("directory")]
+ public async Task<IActionResult> PluginDirectory(string selectedSlug = null)
+ {
+ var remotePlugins = string.IsNullOrWhiteSpace(selectedSlug)
+ ? []
+ : await LoadRemotePlugins();
+ var model = CreatePluginDirectoryViewModel(selectedSlug, remotePlugins);
+ var pluginSourceBaseUri = pluginService.GetPluginSourceBaseUri();
+ var btcpayVersion = pluginService.GetShortBtcpayVersion();
+ var preReleaseEnabled = policiesSettings.PluginPreReleases;
+ model.DirectoryIframeUrl = BuildDirectoryIframeUrl(
+ pluginSourceBaseUri,
+ btcpayVersion,
+ preReleaseEnabled);
+ model.SelectedPluginPanel.EmbeddedDetailsUrl = BuildPluginDetailsEmbedUrl(
+ pluginSourceBaseUri,
+ model.SelectedPluginPanel.SelectedSlug,
+ model.SelectedPluginPanel.InstallVersion,
+ btcpayVersion,
+ preReleaseEnabled);
+ return View(model);
+ }
+
+ [HttpGet("panel")]
+ public async Task<IActionResult> SelectedPluginPanel(string slug)
+ {
+ if (string.IsNullOrWhiteSpace(slug))
+ return BadRequest();
+
+ AvailablePlugin[] remotePlugins;
+ try
+ {
+ remotePlugins = await pluginService.GetRemotePlugins(null);
+ }
+ catch
+ {
+ return StatusCode(StatusCodes.Status503ServiceUnavailable);
+ }
+
+ var model = CreateSelectedPluginPanelViewModel(slug, remotePlugins, GetPluginRuntimeState());
+ var pluginSourceBaseUri = pluginService.GetPluginSourceBaseUri();
+ model.EmbeddedDetailsUrl = BuildPluginDetailsEmbedUrl(
+ pluginSourceBaseUri,
+ model.SelectedSlug,
+ model.InstallVersion,
+ pluginService.GetShortBtcpayVersion(),
+ policiesSettings.PluginPreReleases);
+ return PartialView("_SelectedPluginPanel", model);
+ }
+
+ [HttpPost("uninstall-all")]
+ public IActionResult UnInstallAllDisabledPlugin()
+ {
+ var disabled = pluginService.GetDisabledPlugins();
+ foreach (var d in disabled)
+ pluginService.UninstallPlugin(d.Key);
+ return RedirectToAction(nameof(ListPlugins));
+ }
+
+ [HttpPost("uninstall")]
+ public IActionResult UnInstallPlugin(string plugin)
+ {
+ pluginService.UninstallPlugin(plugin);
+ TempData.SetStatusMessageModel(new StatusMessageModel
+ {
+ Message = stringLocalizer["Plugin scheduled to be uninstalled."].Value,
+ Severity = StatusMessageModel.StatusSeverity.Success
+ });
+
+ return RedirectToAction(nameof(ListPlugins));
+ }
+
+ [HttpPost("enable")]
+ public IActionResult EnablePlugin(string plugin)
+ {
+ pluginService.EnablePlugin(plugin);
+ TempData.SetStatusMessageModel(new StatusMessageModel
+ {
+ Message = stringLocalizer["Plugin scheduled to be enabled."].Value,
+ Severity = StatusMessageModel.StatusSeverity.Success
+ });
+
+ return RedirectToAction(nameof(ListPlugins));
+ }
+
+ [HttpPost("cancel")]
+ public IActionResult CancelPluginCommands(
+ string plugin,
+ string selectedSlug = null)
+ {
+ pluginService.CancelCommands(plugin);
+ TempData.SetStatusMessageModel(new StatusMessageModel
+ {
+ Message = stringLocalizer["Plugin action cancelled."].Value,
+ Severity = StatusMessageModel.StatusSeverity.Success
+ });
+
+ return RedirectToPlugins(selectedSlug);
+ }
+
+ [HttpPost("install")]
+ public async Task<IActionResult> InstallPlugin(
+ string plugin,
+ string version = null,
+ string returnTo = null)
+ {
+ var ctx = new DownloadPluginContext(plugin, version, new(), new(), null);
+ await DownloadPluginAndDependencies(ctx);
+ if (ctx.DependencyFailed.Count == 0)
+ {
+ TempData.SetStatusMessageModel(new StatusMessageModel
+ {
+ Message = stringLocalizer["Plugin scheduled to be installed."].Value,
+ Severity = StatusMessageModel.StatusSeverity.Success
+ });
+ }
+ else
+ {
+ var error = String.Join(" \n", ctx.DependencyFailed
+ .Select(d => $"{d.Key}: {d.Value}"));
+ TempData.SetStatusMessageModel(new StatusMessageModel
+ {
+ Message = stringLocalizer["The plugin could not be downloaded. Try again later."].Value + " \n" + error,
+ Severity = StatusMessageModel.StatusSeverity.Error
+ });
+ }
+
+ return string.Equals(returnTo, "directory", StringComparison.OrdinalIgnoreCase)
+ ? RedirectToAction(nameof(PluginDirectory))
+ : RedirectToAction(nameof(ListPlugins));
+ }
+
+ [HttpPost("upload")]
+ public async Task<IActionResult> UploadPlugin(List<IFormFile> files)
+ {
+ foreach (var formFile in files.Where(file => file.Length > 0).Where(file => file.FileName.IsValidFileName()))
+ {
+ await pluginService.UploadPlugin(formFile);
+ pluginService.InstallPlugin(formFile.FileName.TrimEnd(PluginManager.BTCPayPluginSuffix,
+ StringComparison.InvariantCultureIgnoreCase));
+ }
+
+ TempData.SetStatusMessageModel(new StatusMessageModel
+ {
+ Message = stringLocalizer["Files uploaded, restart server to load plugins"].Value,
+ Severity = StatusMessageModel.StatusSeverity.Success
+ });
+ return RedirectToAction(nameof(ListPlugins));
+ }
+
+ private record DownloadPluginContext(string Plugin, string Version, HashSet<string> Downloaded, Dictionary<string, string> DependencyFailed, VersionCondition VersionCondition);
+
+ private async Task DownloadPluginAndDependencies(DownloadPluginContext ctx)
+ {
+ if (ctx.Downloaded.Contains(ctx.Plugin)
+ ||
+ ctx.DependencyFailed.ContainsKey(ctx.Plugin))
+ return;
+ AvailablePlugin manifest;
+ try
+ {
+ manifest = await pluginService.DownloadRemotePlugin(ctx.Plugin, ctx.Version, ctx.VersionCondition);
+ }
+ catch (Exception ex)
+ {
+ ctx.DependencyFailed.Add(ctx.Plugin, ex.Message);
+ return;
+ }
+
+ foreach (var dep in manifest.Dependencies)
+ {
+ if (!PluginManager.DependencyMet(dep, pluginService.Installed))
+ {
+ if (dep.Identifier.Equals("BTCPayServer", StringComparison.OrdinalIgnoreCase))
+ {
+ ctx.DependencyFailed.Add(ctx.Plugin, $"This condition can't be satisfied {dep}");
+ return;
+ }
+
+ var cond = dep.ParseCondition();
+ var childCtx = ctx with
+ {
+ Plugin = dep.Identifier,
+ Version = null,
+ VersionCondition = cond
+ };
+ if (childCtx.VersionCondition is VersionCondition.Not)
+ {
+ ctx.DependencyFailed.Add(ctx.Plugin, $"The currently installed plugin {dep.Identifier} is incompatible with this plugin.");
+ return;
+ }
+
+ await DownloadPluginAndDependencies(childCtx);
+ if (childCtx.DependencyFailed.ContainsKey(childCtx.Plugin))
+ {
+ ctx.DependencyFailed.Add(ctx.Plugin, $"Failed to download dependency {dep.Identifier}");
+ return;
+ }
+ }
+ }
+
+ pluginService.InstallPlugin(ctx.Plugin);
+ ctx.Downloaded.Add(ctx.Plugin);
+ }
+
+ internal async Task<InstalledPluginsViewModel> CreateInstalledPluginsViewModel(
+ IEnumerable<AvailablePlugin> remotePlugins = null)
+ {
+ remotePlugins ??= await LoadRemotePlugins();
+ var runtimeState = GetPluginRuntimeState();
+ var versionsByIdentifier = remotePlugins
+ .GroupBy(plugin => plugin.Identifier, StringComparer.OrdinalIgnoreCase)
+ .ToDictionary(
+ group => group.Key,
+ group => group.ToArray(),
+ StringComparer.OrdinalIgnoreCase);
+ var requiredPluginIdentifiers = GetRequiredPluginIdentifiers(runtimeState, versionsByIdentifier);
+
+ return new InstalledPluginsViewModel
+ {
+ DisabledPlugins = runtimeState.DisabledVersions
+ .OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase)
+ .Select(pair => CreateDisabledViewModel(
+ pair.Key,
+ pair.Value,
+ versionsByIdentifier.GetValueOrDefault(pair.Key),
+ runtimeState.PendingCommands))
+ .ToList(),
+ InstalledPlugins = runtimeState.VisibleLoadedPlugins
+ .OrderBy(plugin => plugin.Name, StringComparer.OrdinalIgnoreCase)
+ .Select(plugin => CreateInstalledPluginCardViewModel(
+ plugin,
+ versionsByIdentifier.GetValueOrDefault(plugin.Identifier),
+ runtimeState.PendingCommands,
+ requiredPluginIdentifiers.Contains(plugin.Identifier)))
+ .ToList(),
+ PendingActions = runtimeState.PendingCommands
+ .GroupBy(tuple => tuple.plugin, StringComparer.OrdinalIgnoreCase)
+ .Select(group => new PendingPluginActionViewModel
+ {
+ Plugin = group.Key,
+ Action = group.Last().command
+ })
+ .OrderBy(action => action.Plugin, StringComparer.OrdinalIgnoreCase)
+ .ToList()
+ };
+ }
+
+ internal PluginDirectoryViewModel CreatePluginDirectoryViewModel(
+ string selectedSlug,
+ AvailablePlugin[] remotePlugins)
+ {
+ var runtimeState = GetPluginRuntimeState();
+
+ return new PluginDirectoryViewModel
+ {
+ HiddenPluginIdentifiers = runtimeState.VisibleLoadedPlugins
+ .Select(plugin => plugin.Identifier)
+ .Concat(runtimeState.DisabledVersions.Keys)
+ .Where(identifier => !string.IsNullOrEmpty(identifier))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .OrderBy(identifier => identifier, StringComparer.OrdinalIgnoreCase)
+ .ToArray(),
+ HasPendingActions = runtimeState.PendingCommands.Length > 0,
+ SelectedPluginPanel = CreateSelectedPluginPanelViewModel(selectedSlug, remotePlugins, runtimeState)
+ };
+ }
+
+ private PluginSelectedPanelViewModel CreateSelectedPluginPanelViewModel(
+ string selectedSlug,
+ AvailablePlugin[] remotePlugins,
+ PluginRuntimeState runtimeState)
+ {
+ if (string.IsNullOrEmpty(selectedSlug))
+ return new PluginSelectedPanelViewModel();
+
+ var selectedAvailable = remotePlugins.FirstOrDefault(plugin =>
+ plugin.CatalogSlug != null &&
+ plugin.CatalogSlug.Equals(selectedSlug, StringComparison.OrdinalIgnoreCase));
+ if (selectedAvailable is null)
+ return new PluginSelectedPanelViewModel();
+
+ var identifier = selectedAvailable.Identifier;
+ var loadedPlugin = runtimeState.VisibleLoadedPlugins.FirstOrDefault(plugin =>
+ plugin.Identifier.Equals(identifier, StringComparison.OrdinalIgnoreCase));
+ runtimeState.DisabledVersions.TryGetValue(identifier, out var disabledVersion);
+ var pluginCommands = runtimeState.PendingCommands
+ .Where(command => command.plugin.Equals(identifier, StringComparison.OrdinalIgnoreCase))
+ .ToArray();
+ var pendingAction = pluginCommands.LastOrDefault().command;
+ var pendingVersion = pluginCommands.Any(command =>
+ command.command.Equals("install", StringComparison.OrdinalIgnoreCase))
+ ? pluginService.GetPendingPluginManifest("install", identifier)?.Version
+ : null;
+ var canInstall = string.IsNullOrEmpty(pendingAction) &&
+ loadedPlugin is null &&
+ disabledVersion is null;
+
+ return new PluginSelectedPanelViewModel
+ {
+ SelectedSlug = selectedAvailable.CatalogSlug,
+ PluginIdentifier = identifier,
+ PluginName = selectedAvailable.Name,
+ InstalledVersion = loadedPlugin?.Version,
+ DisabledVersion = disabledVersion,
+ PendingAction = pendingAction,
+ PendingVersion = pendingVersion,
+ InstallVersion = canInstall
+ ? GetBestCandidate(remotePlugins.Where(plugin =>
+ plugin.Identifier.Equals(identifier, StringComparison.OrdinalIgnoreCase)))?.Version?.ToString()
+ : null
+ };
+ }
+
+ private PluginRuntimeState GetPluginRuntimeState()
+ {
+ return new PluginRuntimeState(
+ pluginService.LoadedPlugins
+ .Where(plugin => !plugin.SystemPlugin)
+ .ToArray(),
+ pluginService.GetDisabledPlugins(),
+ pluginService.GetPendingCommands());
+ }
+
+ private HashSet<string> GetRequiredPluginIdentifiers(
+ PluginRuntimeState runtimeState,
+ Dictionary<string, AvailablePlugin[]> availableVersionsByIdentifier)
+ {
+ var pendingDeletePlugins = runtimeState.PendingCommands
+ .Where(tuple => tuple.command.Equals("delete", StringComparison.OrdinalIgnoreCase))
+ .Select(tuple => tuple.plugin)
+ .ToHashSet(StringComparer.OrdinalIgnoreCase);
+ var requiredPluginIdentifiers = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var installedPlugin in runtimeState.VisibleLoadedPlugins)
+ {
+ if (pendingDeletePlugins.Contains(installedPlugin.Identifier))
+ continue;
+
+ foreach (var dependency in installedPlugin.Dependencies ?? [])
+ {
+ requiredPluginIdentifiers.Add(dependency.Identifier);
+ }
+ }
+
+ foreach (var pendingCommand in runtimeState.PendingCommands
+ .Where(tuple => tuple.command.Equals("install", StringComparison.OrdinalIgnoreCase) ||
+ tuple.command.Equals("enable", StringComparison.OrdinalIgnoreCase))
+ .GroupBy(tuple => tuple.plugin, StringComparer.OrdinalIgnoreCase)
+ .Select(group => group.Last()))
+ {
+ var pendingPlugin = pluginService.GetPendingPluginManifest(pendingCommand.command, pendingCommand.plugin);
+ if (pendingPlugin is not null)
+ {
+ foreach (var dependency in pendingPlugin.Dependencies ?? [])
+ {
+ requiredPluginIdentifiers.Add(dependency.Identifier);
+ }
+ continue;
+ }
+
+ if (!availableVersionsByIdentifier.TryGetValue(pendingCommand.plugin, out var pendingAvailableVersions))
+ continue;
+
+ foreach (var dependency in pendingAvailableVersions.SelectMany(plugin => plugin.Dependencies ?? []))
+ {
+ requiredPluginIdentifiers.Add(dependency.Identifier);
+ }
+ }
+
+ return requiredPluginIdentifiers;
+ }
+
+ private PluginDisabledViewModel CreateDisabledViewModel(
+ string identifier,
+ Version disabledVersion,
+ IEnumerable<AvailablePlugin> availableVersions,
+ (string command, string plugin)[] pendingCommands)
+ {
+ var bestUpdate = disabledVersion is null
+ ? null
+ : GetBestCandidate(availableVersions?.Where(plugin => plugin.Version > disabledVersion));
+ var pluginCommands = pendingCommands
+ .Where(tuple => tuple.plugin.Equals(identifier, StringComparison.OrdinalIgnoreCase))
+ .ToArray();
+ var hasPendingInstall = pluginCommands.Any(tuple => tuple.command.Equals("install", StringComparison.OrdinalIgnoreCase));
+ var hasPendingDelete = pluginCommands.Any(tuple => tuple.command.Equals("delete", StringComparison.OrdinalIgnoreCase));
+ var hasPendingEnable = pluginCommands.Any(tuple => tuple.command.Equals("enable", StringComparison.OrdinalIgnoreCase));
+ var actions = new List<PluginActionViewModel>();
+ if (hasPendingInstall)
+ {
+ actions.Add(CreateDisabledAction("Marked for update", "btn btn-sm btn-outline-primary"));
+ }
+ else if (bestUpdate is not null && !hasPendingDelete && !hasPendingEnable)
+ {
+ var dependenciesMet = PluginManager.DependenciesMet(bestUpdate.Dependencies ?? [], pluginService.Installed);
+ actions.Add(CreateInstallAction(
+ bestUpdate.Version,
+ "btn btn-sm btn-primary",
+ dependenciesMet ? "Update" : "Schedule update",
+ dependenciesMet ? null : "Schedule upgrade for when the dependencies have been met to ensure a smooth update"));
+ }
+
+ if (hasPendingEnable)
+ {
+ actions.Add(CreateDisabledAction("Marked for enabling", "btn btn-sm btn-outline-primary"));
+ }
+ else if (!hasPendingDelete && !hasPendingInstall)
+ {
+ actions.Add(CreatePostAction("EnablePlugin", "Enable", "btn btn-sm btn-outline-primary"));
+ }
+
+ if (hasPendingDelete)
+ {
+ actions.Add(CreateDisabledAction("Marked for deletion", "btn btn-sm btn-outline-danger"));
+ }
+ else if (!hasPendingEnable && !hasPendingInstall)
+ {
+ actions.Add(CreatePostAction("UnInstallPlugin", "Uninstall", "btn btn-sm btn-outline-danger"));
+ }
+
+ return new PluginDisabledViewModel
+ {
+ Identifier = identifier,
+ DisabledVersion = disabledVersion,
+ RecommendedUpdateVersion = bestUpdate?.Version,
+ Actions = actions
+ };
+ }
+
+ private PluginInstalledCardViewModel CreateInstalledPluginCardViewModel(
+ IBTCPayServerPlugin plugin,
+ IEnumerable<AvailablePlugin> availableVersions,
+ (string command, string plugin)[] pendingCommands,
+ bool isRequiredByOtherPlugins)
+ {
+ var bestAvailable = GetBestCandidate(availableVersions);
+ var bestUpdate = GetBestCandidate(availableVersions?.Where(candidate => candidate.Version > plugin.Version));
+ var pluginCommands = pendingCommands
+ .Where(tuple => tuple.plugin.Equals(plugin.Identifier, StringComparison.OrdinalIgnoreCase))
+ .ToArray();
+ var lastPendingAction = pluginCommands.LastOrDefault().command;
+ var pendingInstallVersion = pluginCommands.Any(tuple => tuple.command.Equals("install", StringComparison.OrdinalIgnoreCase))
+ ? pluginService.GetPendingPluginManifest("install", plugin.Identifier)?.Version
+ : null;
+ var actions = new List<PluginActionViewModel>();
+ if (!string.IsNullOrEmpty(lastPendingAction))
+ {
+ var suffix = pendingInstallVersion is null ? string.Empty : $" of {pendingInstallVersion}";
+ actions.Add(new PluginActionViewModel
+ {
+ FormAction = "CancelPluginCommands",
+ Label = $"Cancel pending {lastPendingAction}{suffix}",
+ CssClass = "btn btn-outline-secondary"
+ });
+ }
+ else
+ {
+ if (bestUpdate is not null)
+ {
+ var dependenciesMet = PluginManager.DependenciesMet(bestUpdate.Dependencies ?? [], pluginService.Installed);
+ actions.Add(CreateInstallAction(
+ bestUpdate.Version,
+ "btn btn-secondary",
+ dependenciesMet ? "Update" : "Schedule update",
+ dependenciesMet ? null : "Schedule upgrade for when the dependencies have been met to ensure a smooth update"));
+ }
+
+ if (isRequiredByOtherPlugins)
+ {
+ actions.Add(CreateDisabledAction(
+ "Uninstall",
+ "btn btn-outline-danger",
+ "This plugin cannot be uninstalled as it is depended on by other plugins."));
+ }
+ else
+ {
+ actions.Add(CreatePostAction("UnInstallPlugin", "Uninstall", "btn btn-outline-danger"));
+ }
+ }
+
+ return new PluginInstalledCardViewModel
+ {
+ Current = CreatePluginInfo(plugin, bestAvailable),
+ Update = bestUpdate is null
+ ? null
+ : new PluginInfoViewModel
+ {
+ Description = bestUpdate.Description,
+ Version = bestUpdate.Version,
+ Dependencies = CreateDependencyViewModels(bestUpdate.Dependencies)
+ },
+ PendingAction = lastPendingAction,
+ Actions = actions
+ };
+ }
+
+ private AvailablePlugin GetBestCandidate(IEnumerable<AvailablePlugin> plugins)
+ {
+ var ordered = plugins?
+ .OrderByDescending(plugin => plugin.Version)
+ .ToArray() ?? [];
+ return ordered.FirstOrDefault(plugin => PluginManager.DependenciesMet(plugin.Dependencies ?? [], pluginService.Installed)) ?? ordered.FirstOrDefault();
+ }
+
+ private static PluginActionViewModel CreateInstallAction(
+ Version version,
+ string cssClass,
+ string label,
+ string tooltip = null)
+ {
+ return new PluginActionViewModel
+ {
+ FormAction = "InstallPlugin",
+ Label = label,
+ CssClass = cssClass,
+ Version = version?.ToString(),
+ Tooltip = tooltip
+ };
+ }
+
+ private static PluginActionViewModel CreatePostAction(string action, string label, string cssClass)
+ {
+ return new PluginActionViewModel
+ {
+ FormAction = action,
+ Label = label,
+ CssClass = cssClass
+ };
+ }
+
+ private static PluginActionViewModel CreateDisabledAction(string label, string cssClass, string tooltip = null)
+ {
+ return new PluginActionViewModel
+ {
+ Label = label,
+ CssClass = cssClass,
+ Tooltip = tooltip
+ };
+ }
+
+ private PluginInfoViewModel CreatePluginInfo(
+ IBTCPayServerPlugin plugin,
+ AvailablePlugin metadata)
+ {
+ return new PluginInfoViewModel
+ {
+ Identifier = plugin.Identifier,
+ Name = plugin.Name,
+ Description = plugin.Description,
+ Version = plugin.Version,
+ Documentation = SafeExternalUrl(metadata?.Documentation),
+ Source = SafeExternalUrl(metadata?.Source),
+ Author = metadata?.Author,
+ AuthorLink = SafeExternalUrl(metadata?.AuthorLink),
+ Dependencies = CreateDependencyViewModels(plugin.Dependencies)
+ };
+ }
+
+ private static string SafeExternalUrl(string url)
+ {
+ return Uri.TryCreate(url, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)
+ ? uri.AbsoluteUri
+ : null;
+ }
+
+ private List<PluginDependencyViewModel> CreateDependencyViewModels(IEnumerable<IBTCPayServerPlugin.PluginDependency> dependencies)
+ {
+ return (dependencies ?? [])
+ .Select(dependency => new PluginDependencyViewModel
+ {
+ Display = dependency.ToString(),
+ IsMet = PluginManager.DependencyMet(dependency, pluginService.Installed)
+ })
+ .ToList();
+ }
+
+ private async Task<AvailablePlugin[]> LoadRemotePlugins()
+ {
+ try
+ {
+ return await pluginService.GetRemotePlugins(null);
+ }
+ catch (Exception ex)
+ {
+ TempData.SetStatusMessageModel(new StatusMessageModel
+ {
+ Severity = StatusMessageModel.StatusSeverity.Error,
+ Message = stringLocalizer["Remote plugins lookup failed. Try again later. Error: {0}", ex.Message].Value
+ });
+ return [];
+ }
+ }
+
+ private IActionResult RedirectToPlugins(string selectedSlug)
+ {
+ return string.IsNullOrEmpty(selectedSlug)
+ ? RedirectToAction(nameof(ListPlugins))
+ : RedirectToAction(nameof(PluginDirectory), new { selectedSlug });
+ }
+
+ internal static string BuildDirectoryIframeUrl(
+ Uri pluginSourceBaseUri,
+ string btcpayVersion,
+ bool includePreRelease)
+ {
+ if (pluginSourceBaseUri is null) return null;
+
+ var baseUri = PluginBuilderClient.NormalizeBaseAddress(pluginSourceBaseUri);
+ var builder = new UriBuilder(new Uri(baseUri, "public/plugins"));
+ var query = new List<string> { "embed=1" };
+ if (!string.IsNullOrEmpty(btcpayVersion))
+ {
+ query.Add($"btcpayVersion={Uri.EscapeDataString(btcpayVersion)}");
+ query.Add($"includePreRelease={includePreRelease.ToString().ToLowerInvariant()}");
+ }
+ builder.Query = string.Join("&", query);
+ return builder.Uri.ToString();
+ }
+
+ private static string BuildPluginDetailsEmbedUrl(
+ Uri pluginSourceBaseUri,
+ string selectedSlug,
+ string selectedVersion,
+ string btcpayVersion,
+ bool includePreRelease)
+ {
+ if (pluginSourceBaseUri is null || string.IsNullOrEmpty(selectedSlug)) return null;
+
+ var baseUri = PluginBuilderClient.NormalizeBaseAddress(pluginSourceBaseUri);
+ var builder = new UriBuilder(new Uri(baseUri, $"public/plugins/{Uri.EscapeDataString(selectedSlug)}"));
+ var query = new List<string> { "embed=1" };
+ if (!string.IsNullOrEmpty(btcpayVersion))
+ {
+ query.Add($"btcpayVersion={Uri.EscapeDataString(btcpayVersion)}");
+ query.Add($"includePreRelease={includePreRelease.ToString().ToLowerInvariant()}");
+ }
+ if (!string.IsNullOrEmpty(selectedVersion)) query.Add($"version={Uri.EscapeDataString(selectedVersion)}");
+
+ builder.Query = string.Join("&", query);
+ return builder.Uri.ToString();
+ }
+
+ private sealed record PluginRuntimeState(
+ IBTCPayServerPlugin[] VisibleLoadedPlugins,
+ Dictionary<string, Version> DisabledVersions,
+ (string command, string plugin)[] PendingCommands);
+}
diff --git a/BTCPayServer/Plugins/PluginManager/Models/PluginManagementViewModels.cs b/BTCPayServer/Plugins/PluginManager/Models/PluginManagementViewModels.cs
new file mode 100644
index 0000000..be61892
--- /dev/null
+++ b/BTCPayServer/Plugins/PluginManager/Models/PluginManagementViewModels.cs
@@ -0,0 +1,83 @@
+using System;
+using System.Collections.Generic;
+
+namespace BTCPayServer.Plugins.PluginManagement.Models
+{
+ public class InstalledPluginsViewModel
+ {
+ public List<PluginDisabledViewModel> DisabledPlugins { get; set; } = [];
+ public List<PluginInstalledCardViewModel> InstalledPlugins { get; set; } = [];
+ public List<PendingPluginActionViewModel> PendingActions { get; set; } = [];
+ }
+
+ public class PluginDirectoryViewModel
+ {
+ public string DirectoryIframeUrl { get; set; }
+ public string[] HiddenPluginIdentifiers { get; set; } = [];
+ public bool HasPendingActions { get; set; }
+ public PluginSelectedPanelViewModel SelectedPluginPanel { get; set; } = new();
+ }
+
+ public class PluginSelectedPanelViewModel
+ {
+ public string SelectedSlug { get; set; }
+ public string EmbeddedDetailsUrl { get; set; }
+ public string PluginIdentifier { get; set; }
+ public string PluginName { get; set; }
+ public Version InstalledVersion { get; set; }
+ public Version DisabledVersion { get; set; }
+ public string PendingAction { get; set; }
+ public Version PendingVersion { get; set; }
+ public string InstallVersion { get; set; }
+ }
+
+ public class PluginDisabledViewModel
+ {
+ public string Identifier { get; set; }
+ public Version DisabledVersion { get; set; }
+ public Version RecommendedUpdateVersion { get; set; }
+ public List<PluginActionViewModel> Actions { get; set; } = [];
+ }
+
+ public class PluginInstalledCardViewModel
+ {
+ public PluginInfoViewModel Current { get; set; }
+ public PluginInfoViewModel Update { get; set; }
+ public string PendingAction { get; set; }
+ public List<PluginActionViewModel> Actions { get; set; } = [];
+ }
+
+ public class PendingPluginActionViewModel
+ {
+ public string Plugin { get; set; }
+ public string Action { get; set; }
+ }
+
+ public class PluginInfoViewModel
+ {
+ public string Identifier { get; set; }
+ public string Name { get; set; }
+ public string Description { get; set; }
+ public Version Version { get; set; }
+ public string Documentation { get; set; }
+ public string Source { get; set; }
+ public string Author { get; set; }
+ public string AuthorLink { get; set; }
+ public List<PluginDependencyViewModel> Dependencies { get; set; } = [];
+ }
+
+ public class PluginDependencyViewModel
+ {
+ public string Display { get; set; }
+ public bool IsMet { get; set; }
+ }
+
+ public class PluginActionViewModel
+ {
+ public string FormAction { get; set; }
+ public string Label { get; set; }
+ public string CssClass { get; set; }
+ public string Version { get; set; }
+ public string Tooltip { get; set; }
+ }
+}
diff --git a/BTCPayServer/Plugins/PluginManager/PluginBuilderClient.cs b/BTCPayServer/Plugins/PluginManager/PluginBuilderClient.cs
new file mode 100644
index 0000000..9461391
--- /dev/null
+++ b/BTCPayServer/Plugins/PluginManager/PluginBuilderClient.cs
@@ -0,0 +1,126 @@
+using System;
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Text;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+
+namespace BTCPayServer.Plugins
+{
+ public class PublishedVersion
+ {
+ public class BuildInfoClass
+ {
+ public string gitCommit { get; set; }
+ public string pluginDir { get; set; }
+ public string gitRepository { get; set; }
+#nullable enable
+ static Regex GithubRepositoryRegex = new Regex("^https://(www\\.)?github\\.com/([^/]+)/([^/]+)/?");
+ public record GithubRepository(string Owner, string RepositoryName)
+ {
+ public string? GetSourceUrl(string commit, string pluginDir)
+ {
+ if (commit is null)
+ return null;
+ return $"https://github.com/{Owner}/{RepositoryName}/tree/{commit}/{pluginDir}";
+ }
+ }
+ public GithubRepository? GetGithubRepository()
+ {
+ if (gitRepository is null)
+ return null;
+ var match = GithubRepositoryRegex.Match(gitRepository);
+ if (!match.Success)
+ return null;
+ return new GithubRepository(match.Groups[2].Value, match.Groups[3].Value);
+ }
+#nullable restore
+ [JsonExtensionData]
+ public IDictionary<string, JToken> AdditionalData { get; set; }
+ }
+ public string ProjectSlug { get; set; }
+ public long BuildId { get; set; }
+ public BuildInfoClass BuildInfo { get; set; }
+ public JObject ManifestInfo { get; set; }
+ public string Documentation { get; set; }
+ }
+
+ public record InstalledPluginRequest(string Identifier, string Version);
+
+ public class PluginBuilderClient(HttpClient httpClient)
+ {
+ public HttpClient HttpClient { get; } = httpClient;
+
+ internal static Uri NormalizeBaseAddress(Uri baseAddress)
+ {
+ var baseUrl = baseAddress.GetLeftPart(UriPartial.Path);
+ return new Uri(
+ baseUrl.EndsWith("/", StringComparison.Ordinal) ? baseUrl : baseUrl + '/',
+ UriKind.Absolute);
+ }
+
+ static JsonSerializerSettings serializerSettings = new() { ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver() };
+ public async Task<PublishedVersion[]> GetPublishedVersions(string btcpayVersion, bool includePreRelease, string searchPluginName = null, bool? includeAllVersions = null, CancellationToken cancellationToken = default)
+ {
+ var queryString = $"?includePreRelease={includePreRelease}";
+ if (btcpayVersion is not null)
+ queryString += $"&btcpayVersion={Uri.EscapeDataString(btcpayVersion)}";
+ if (searchPluginName is not null)
+ queryString += $"&searchPluginName={Uri.EscapeDataString(searchPluginName)}";
+ if (includeAllVersions is not null)
+ queryString += $"&includeAllVersions={includeAllVersions}";
+ var result = await HttpClient.GetStringAsync($"api/v1/plugins{queryString}", cancellationToken);
+ return JsonConvert.DeserializeObject<PublishedVersion[]>(result, serializerSettings) ?? throw new InvalidOperationException();
+ }
+ public async Task<PublishedVersion> GetPlugin(string pluginSlug, string version)
+ {
+ try
+ {
+ var result = await HttpClient.GetStringAsync($"api/v1/plugins/{pluginSlug}/versions/{version}");
+ return JsonConvert.DeserializeObject<PublishedVersion>(result, serializerSettings);
+ }
+ catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
+ {
+ return null;
+ }
+ }
+
+ public async Task<PublishedVersion[]> GetPluginVersionsForDownload(string identifier, string btcpayVersion, bool includePreRelease = false, bool includeAllVersions = false)
+ {
+ var queryString = $"?btcpayVersion={btcpayVersion}&includePreRelease={includePreRelease}&includeAllVersions={includeAllVersions}";
+ var url = $"api/v1/plugins/{Uri.EscapeDataString(identifier)}{queryString}";
+
+ var result = await HttpClient.GetStringAsync(url);
+ return JsonConvert.DeserializeObject<PublishedVersion[]>(result, serializerSettings)
+ ?? throw new InvalidOperationException();
+ }
+
+ public async Task<PublishedVersion[]> GetInstalledPluginsUpdates(
+ string btcpayVersion,
+ bool includePreRelease,
+ IEnumerable<InstalledPluginRequest> plugins,
+ CancellationToken cancellationToken = default)
+ {
+ var queryString = $"?includePreRelease={includePreRelease}";
+ if (!string.IsNullOrWhiteSpace(btcpayVersion))
+ queryString += $"&btcpayVersion={Uri.EscapeDataString(btcpayVersion)}";
+
+ var json = JsonConvert.SerializeObject(plugins, serializerSettings);
+ using var content = new StringContent(json, Encoding.UTF8, "application/json");
+
+ using var resp = await HttpClient.PostAsync($"api/v1/plugins/updates{queryString}", content, cancellationToken);
+ resp.EnsureSuccessStatusCode();
+
+ var body = await resp.Content.ReadAsStringAsync(cancellationToken);
+ var result = JsonConvert.DeserializeObject<PublishedVersion[]>(body, serializerSettings);
+
+ if (result is null)
+ throw new JsonException("Plugin updates response deserialized to null.");
+
+ return result;
+ }
+ }
+}
diff --git a/BTCPayServer/Plugins/PluginManager/PluginExceptionHandler.cs b/BTCPayServer/Plugins/PluginManager/PluginExceptionHandler.cs
new file mode 100644
index 0000000..f3fe36d
--- /dev/null
+++ b/BTCPayServer/Plugins/PluginManager/PluginExceptionHandler.cs
@@ -0,0 +1,51 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Threading;
+using System.Threading.Tasks;
+using BTCPayServer.Configuration;
+using BTCPayServer.Logging;
+using Microsoft.AspNetCore.Diagnostics;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace BTCPayServer.Plugins
+{
+ public class PluginExceptionHandler(IOptions<DataDirectories> options, IHostApplicationLifetime applicationLifetime, Logs logs)
+ : IExceptionHandler
+ {
+ readonly string _pluginDir = options.Value.PluginDir;
+
+ public ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
+ {
+ if (!GetDisablePluginIfCrash(httpContext) ||
+ !PluginManager.IsExceptionByPlugin(exception, out var plugin) ||
+ plugin.Instance.SystemPlugin)
+ return ValueTask.FromResult(false);
+ logs.Configuration.LogError(exception, $"Unhandled exception caused by plugin '{plugin.Assembly.GetName().Name}', disabling it and restarting...");
+
+ if (Debugger.IsAttached)
+ {
+ logs.Configuration.LogWarning("Debugger attached detected, so we didn't disable the plugin and do not restart the server");
+ return ValueTask.FromResult(false);
+ }
+ PluginManager.DisablePlugin(_pluginDir, plugin);
+ _ = Task.Delay(3000).ContinueWith((t) => applicationLifetime.StopApplication());
+ // Returning true here means we will see Error 500 error message.
+ // Returning false means that the user will see a stacktrace.
+ return ValueTask.FromResult(false);
+ }
+
+ public static bool GetDisablePluginIfCrash(HttpContext httpContext)
+ {
+ return httpContext.Items.TryGetValue("DisablePluginIfCrash", out object renderingDashboard) ||
+ renderingDashboard is not true;
+ }
+ public static void SetDisablePluginIfCrash(HttpContext httpContext)
+ {
+ httpContext.Items.TryAdd("DisablePluginIfCrash", true);
+ }
+ }
+}
diff --git a/BTCPayServer/Plugins/PluginManager/PluginManagerPlugin.cs b/BTCPayServer/Plugins/PluginManager/PluginManagerPlugin.cs
new file mode 100644
index 0000000..8e07682
--- /dev/null
+++ b/BTCPayServer/Plugins/PluginManager/PluginManagerPlugin.cs
@@ -0,0 +1,63 @@
+using System;
+using System.Net.Http;
+using BTCPayServer.Abstractions.Contracts;
+using BTCPayServer.Abstractions.Models;
+using BTCPayServer.Client;
+using BTCPayServer.HostedServices;
+using BTCPayServer.Plugins.GlobalSearch;
+using BTCPayServer.Plugins.PluginManagement.Controllers;
+using BTCPayServer.Services;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace BTCPayServer.Plugins.PluginManagement;
+
+public class PluginManagerPlugin : BaseBTCPayServerPlugin
+{
+ public const string Area = "PluginManager";
+ public override string Identifier => "BTCPayServer.Plugins.PluginManager";
+ public override string Name => "Plugin Manager";
+ public override string Description => "Manage BTCPay Server plugins.";
+
+ public override void Execute(IServiceCollection services)
+ {
+ services.AddHttpClient<PluginBuilderClient>((provider, httpClient) =>
+ ConfigurePluginBuilderClient(provider.GetRequiredService<PoliciesSettings>(), httpClient));
+ services.AddTransient<PluginService>();
+ services.AddScheduledTask<PluginUpdateFetcher>(TimeSpan.FromDays(1));
+ services.AddSingleton<INotificationHandler, PluginUpdateNotification.Handler>();
+ services.AddStaticSearch([
+ new ActionResultItemViewModel
+ {
+ RequiredPolicy = Policies.CanModifyServerSettings,
+ Title = "View installed plugins",
+ Action = nameof(UIPluginManagerController.ListPlugins),
+ Controller = "UIPluginManager",
+ Values = _ => new { area = Area },
+ Category = "Server",
+ Keywords = ["Plugins", "Installed", "Update", "Configure"]
+ },
+ new ActionResultItemViewModel
+ {
+ RequiredPolicy = Policies.CanModifyServerSettings,
+ Title = "Browse the plugin directory",
+ Action = nameof(UIPluginManagerController.PluginDirectory),
+ Controller = "UIPluginManager",
+ Values = _ => new { area = Area },
+ Category = "Server",
+ Keywords = ["Plugins", "Directory", "Install"]
+ }
+ ]);
+ }
+
+ internal static void ConfigurePluginBuilderClient(PoliciesSettings policiesSettings, HttpClient httpClient)
+ {
+ var pluginSource = policiesSettings.PluginSource ?? PoliciesSettings.DefaultPluginSource;
+ if (!Uri.TryCreate(pluginSource, UriKind.Absolute, out var baseAddress) ||
+ (baseAddress.Scheme != Uri.UriSchemeHttps && baseAddress.Scheme != Uri.UriSchemeHttp))
+ {
+ baseAddress = new Uri(PoliciesSettings.DefaultPluginSource, UriKind.Absolute);
+ }
+
+ httpClient.BaseAddress = PluginBuilderClient.NormalizeBaseAddress(baseAddress);
+ }
+}
diff --git a/BTCPayServer/Plugins/PluginManager/PluginService.cs b/BTCPayServer/Plugins/PluginManager/PluginService.cs
new file mode 100644
index 0000000..5d227d7
--- /dev/null
+++ b/BTCPayServer/Plugins/PluginManager/PluginService.cs
@@ -0,0 +1,278 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using BTCPayServer.Abstractions.Contracts;
+using BTCPayServer.Configuration;
+using BTCPayServer.Services;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+
+namespace BTCPayServer.Plugins
+{
+ public class PluginService
+ {
+ private readonly IOptions<DataDirectories> _dataDirectories;
+ private readonly PoliciesSettings _policiesSettings;
+ private readonly PluginBuilderClient _pluginBuilderClient;
+
+ public PluginService(
+ IEnumerable<IBTCPayServerPlugin> btcPayServerPlugins,
+ PluginBuilderClient pluginBuilderClient,
+ IOptions<DataDirectories> dataDirectories,
+ PoliciesSettings policiesSettings,
+ BTCPayServerEnvironment env
+ )
+ {
+ LoadedPlugins = btcPayServerPlugins;
+ Installed = btcPayServerPlugins.ToDictionary(p => p.Identifier, p => p.Version, StringComparer.OrdinalIgnoreCase);
+ _pluginBuilderClient = pluginBuilderClient;
+ _dataDirectories = dataDirectories;
+ _policiesSettings = policiesSettings;
+ Env = env;
+ }
+
+ public Dictionary<string, Version> Installed { get; set; }
+
+ public IEnumerable<IBTCPayServerPlugin> LoadedPlugins { get; }
+ public BTCPayServerEnvironment Env { get; }
+
+ internal AvailablePlugin GetPendingPluginManifest(string command, string plugin)
+ {
+ if (string.IsNullOrEmpty(plugin)) return null;
+
+ var pluginPath = Path.Combine(_dataDirectories.Value.PluginDir, plugin);
+ var manifestFileName = command switch
+ {
+ "install" => pluginPath + ".json",
+ "enable" => Path.Combine(pluginPath, plugin + ".json"),
+ _ => null
+ };
+ if (manifestFileName is null || !File.Exists(manifestFileName)) return null;
+ return JObject.Parse(File.ReadAllText(manifestFileName)).ToObject<AvailablePlugin>();
+ }
+
+ internal string GetShortBtcpayVersion() => Env.Version.TrimStart('v').Split('+')[0];
+ internal Uri GetPluginSourceBaseUri() => _pluginBuilderClient.HttpClient.BaseAddress;
+
+ public async Task<AvailablePlugin[]> GetRemotePlugins(string searchPluginName, CancellationToken cancellationToken = default)
+ {
+ string btcpayVersion = GetShortBtcpayVersion();
+ var versions = await _pluginBuilderClient.GetPublishedVersions(
+ btcpayVersion, _policiesSettings.PluginPreReleases, searchPluginName, cancellationToken: cancellationToken);
+
+ var plugins = versions
+ .Select(MapToAvailablePlugin)
+ .Where(p => p is not null)
+ .Select(p => p!)
+ .ToList();
+
+ var listedIds = new HashSet<string>(
+ plugins.Select(p => p.Identifier),
+ StringComparer.OrdinalIgnoreCase);
+
+ var loadedToCheck = LoadedPlugins
+ .Where(p => !p.SystemPlugin && !listedIds.Contains(p.Identifier))
+ .Select(p => new InstalledPluginRequest(p.Identifier, p.Version.ToString()))
+ .ToList();
+
+ if (loadedToCheck.Count <= 0) return plugins.ToArray();
+
+ var updates = await _pluginBuilderClient.GetInstalledPluginsUpdates(
+ btcpayVersion,
+ _policiesSettings.PluginPreReleases,
+ loadedToCheck, cancellationToken: cancellationToken);
+
+ if (updates is { Length: > 0 })
+ {
+ plugins.AddRange(
+ updates.Select(MapToAvailablePlugin)
+ .Where(p => p is not null)
+ .Select(p => p!)
+ );
+ }
+
+ return plugins.ToArray();
+ }
+
+#nullable enable
+ private static AvailablePlugin? MapToAvailablePlugin(PublishedVersion publishedVersion)
+ {
+ if (publishedVersion.ManifestInfo is null)
+ return null;
+
+ var availablePlugin = publishedVersion.ManifestInfo.ToObject<AvailablePlugin>();
+ if (availablePlugin is null)
+ throw new InvalidDataException($"Manifest deserialized to null BuildId: {publishedVersion.BuildId} PluginSlug: {publishedVersion.ProjectSlug}");
+
+ availablePlugin.CatalogSlug = publishedVersion.ProjectSlug;
+ availablePlugin.Documentation = publishedVersion.Documentation;
+ var buildInfo = publishedVersion.BuildInfo;
+ var github = buildInfo?.GetGithubRepository();
+ if (buildInfo is not null && github is not null)
+ {
+ availablePlugin.Source = github.GetSourceUrl(buildInfo.gitCommit, buildInfo.pluginDir);
+ availablePlugin.Author = github.Owner;
+ availablePlugin.AuthorLink = $"https://github.com/{github.Owner}";
+ }
+ availablePlugin.SystemPlugin = false;
+ return availablePlugin;
+ }
+#nullable restore
+
+ public async Task<AvailablePlugin> DownloadRemotePlugin(string pluginIdentifier, string version, VersionCondition condition = null)
+ {
+ string btcpayVersion = GetShortBtcpayVersion();
+ var versions = await _pluginBuilderClient.GetPluginVersionsForDownload(pluginIdentifier,
+ btcpayVersion, _policiesSettings.PluginPreReleases, includeAllVersions: true);
+ var compatiblePlugins = versions
+ .Select(v => v.ManifestInfo?.ToObject<AvailablePlugin>());
+ var selectedVersion = SelectCompatiblePluginVersion(pluginIdentifier, version, condition, compatiblePlugins);
+
+ var dest = _dataDirectories.Value.PluginDir;
+ var filedest = Path.Join(dest, pluginIdentifier + ".btcpay");
+ var filemanifestdest = Path.Join(dest, pluginIdentifier + ".json");
+ var pluginSelector = $"[{Uri.EscapeDataString(pluginIdentifier)}]";
+ var selectedVersionParameter = Uri.EscapeDataString(selectedVersion.ToString());
+ Directory.CreateDirectory(Path.GetDirectoryName(filedest));
+ var url = $"api/v1/plugins/{pluginSelector}/versions/{selectedVersionParameter}/download";
+ var publishedVersion = await _pluginBuilderClient.GetPlugin(pluginSelector, selectedVersionParameter);
+ if (publishedVersion is null)
+ throw new InvalidDataException($"Plugin version not found for {pluginIdentifier} {selectedVersion}.");
+ if (publishedVersion.ManifestInfo is null)
+ throw new InvalidDataException($"Plugin manifest not found for {pluginIdentifier} {selectedVersion}.");
+ var manifest = publishedVersion.ManifestInfo.ToObject<AvailablePlugin>() ??
+ throw new InvalidDataException($"Plugin manifest deserialized to null for {pluginIdentifier} {selectedVersion}.");
+ if (!string.Equals(manifest.Identifier, pluginIdentifier, StringComparison.OrdinalIgnoreCase))
+ throw new InvalidDataException($"Plugin manifest identifier {manifest.Identifier} does not match requested plugin {pluginIdentifier}.");
+ if (!selectedVersion.Equals(manifest.Version))
+ throw new InvalidDataException($"Plugin manifest version {manifest.Version} does not match requested version {selectedVersion}.");
+ using var resp2 = await _pluginBuilderClient.HttpClient.GetAsync(url);
+ resp2.EnsureSuccessStatusCode();
+ await using var fs = new FileStream(filedest, FileMode.Create, FileAccess.ReadWrite);
+ await resp2.Content.CopyToAsync(fs);
+ await fs.FlushAsync();
+ await File.WriteAllTextAsync(filemanifestdest, JsonConvert.SerializeObject(manifest, Formatting.Indented));
+ return manifest;
+ }
+
+ internal static Version SelectCompatiblePluginVersion(
+ string pluginIdentifier,
+ string version,
+ VersionCondition condition,
+ IEnumerable<AvailablePlugin> compatiblePlugins)
+ {
+ var potentialVersions = compatiblePlugins
+ .Where(v => v is not null)
+ .Where(v => string.Equals(v.Identifier, pluginIdentifier, StringComparison.OrdinalIgnoreCase))
+ .Where(v => v.Version is not null)
+ .Select(v => v.Version)
+ .Distinct()
+ .ToList();
+ if (potentialVersions.Count == 0)
+ {
+ throw new InvalidOperationException($"Plugin {pluginIdentifier} not found for this BTCPay Server version.");
+ }
+
+ if (version is not null)
+ {
+ if (!Version.TryParse(version, out var requestedVersion))
+ throw new InvalidOperationException($"Plugin {pluginIdentifier} version {version} is invalid.");
+ if (!potentialVersions.Contains(requestedVersion))
+ throw new InvalidOperationException($"Plugin {pluginIdentifier} version {version} is not compatible with this BTCPay Server.");
+ if (condition is not null && !condition.IsFulfilled(requestedVersion))
+ throw new InvalidOperationException($"Plugin {pluginIdentifier} version {version} does not satisfy condition {condition}.");
+ return requestedVersion;
+ }
+
+ var selectedVersion = condition is null
+ ? potentialVersions.OrderDescending().First()
+ : potentialVersions.OrderDescending().FirstOrDefault(condition.IsFulfilled);
+ return selectedVersion ?? throw new InvalidOperationException($"No version of plugin {pluginIdentifier} can satisfy condition {condition}");
+ }
+
+ public void InstallPlugin(string plugin)
+ {
+ PluginManager.QueueCommands(_dataDirectories.Value.PluginDir, ("install", plugin));
+ }
+
+ public async Task UploadPlugin(IFormFile plugin)
+ {
+ var dest = _dataDirectories.Value.PluginDir;
+ var filedest = Path.Combine(dest, plugin.FileName);
+ Directory.CreateDirectory(Path.GetDirectoryName(filedest));
+ if (Path.GetExtension(filedest) == PluginManager.BTCPayPluginSuffix)
+ {
+ await using var stream = new FileStream(filedest, FileMode.Create);
+ await plugin.CopyToAsync(stream);
+ }
+ }
+
+ public void UninstallPlugin(string plugin)
+ {
+ var dest = _dataDirectories.Value.PluginDir;
+ PluginManager.CancelCommands(dest, plugin);
+ PluginManager.QueueCommands(dest, ("delete", plugin));
+ }
+
+ public void EnablePlugin(string plugin){
+ PluginManager.QueueCommands(_dataDirectories.Value.PluginDir, ("enable", plugin));
+ }
+
+ public class AvailablePlugin
+ {
+ public string Identifier { get; set; }
+ public string CatalogSlug { get; set; }
+ public string Name { get; set; }
+ public Version Version { get; set; }
+ public string Description { get; set; }
+ public bool SystemPlugin { get; set; } = false;
+
+ public IBTCPayServerPlugin.PluginDependency[] Dependencies { get; set; } = Array.Empty<IBTCPayServerPlugin.PluginDependency>();
+ public string Documentation { get; set; }
+ public string Source { get; set; }
+ public string Author { get; set; }
+ public string AuthorLink { 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("type")] public string Type { get; set; }
+ [JsonProperty("path")] public string Path { get; set; }
+
+ [JsonProperty("download_url")] public string DownloadUrl { get; set; }
+ }
+
+ public (string command, string plugin)[] GetPendingCommands()
+ {
+ return PluginManager.GetPendingCommands(_dataDirectories.Value.PluginDir);
+ }
+
+ public void CancelCommands(string plugin)
+ {
+ PluginManager.CancelCommands(_dataDirectories.Value.PluginDir, plugin);
+ }
+
+ public Dictionary<string, Version> GetDisabledPlugins()
+ {
+ return PluginManager.GetDisabledPlugins(_dataDirectories.Value.PluginDir);
+ }
+ }
+}
diff --git a/BTCPayServer/Plugins/PluginManager/PluginUpdateFetcher.cs b/BTCPayServer/Plugins/PluginManager/PluginUpdateFetcher.cs
new file mode 100644
index 0000000..dac53bd
--- /dev/null
+++ b/BTCPayServer/Plugins/PluginManager/PluginUpdateFetcher.cs
@@ -0,0 +1,115 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using BTCPayServer.Abstractions.Contracts;
+using BTCPayServer.Configuration;
+using BTCPayServer.Plugins;
+using BTCPayServer.Plugins.PluginManagement;
+using BTCPayServer.Plugins.PluginManagement.Controllers;
+using BTCPayServer.Services;
+using BTCPayServer.Services.Notifications;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.Localization;
+
+namespace BTCPayServer.HostedServices
+{
+ internal class PluginUpdateNotification : BaseNotification
+ {
+ private const string Type = "pluginupdate";
+
+ internal class Handler(LinkGenerator linkGenerator, BTCPayServerOptions options, IStringLocalizer StringLocalizer) : NotificationHandler<PluginUpdateNotification>
+ {
+ public override string NotificationType => Type;
+
+ public override (string identifier, string name)[] Meta => [(Type, StringLocalizer["Plugin update"])];
+
+ protected override void FillViewModel(PluginUpdateNotification notification, NotificationViewModel vm)
+ {
+ vm.Identifier = notification.Identifier;
+ vm.Type = notification.NotificationType;
+ vm.Body = StringLocalizer["New {0} plugin version {1} released!", notification.Name, notification.Version];
+ vm.ActionLink = linkGenerator.GetPathByAction(
+ action: nameof(UIPluginManagerController.ListPlugins),
+ controller: "UIPluginManager",
+ values: new { area = PluginManagerPlugin.Area },
+ pathBase: options.RootPath,
+ fragment: new FragmentString($"#{Uri.EscapeDataString(notification.PluginIdentifier)}"));
+ }
+ }
+
+ public PluginUpdateNotification()
+ {
+ }
+
+ public PluginUpdateNotification(PluginService.AvailablePlugin plugin)
+ {
+ Name = plugin.Name;
+ PluginIdentifier = plugin.Identifier;
+ Version = plugin.Version.ToString();
+ }
+
+ public string PluginIdentifier { get; set; }
+
+ public string Name { get; set; }
+
+ public string Version { get; set; }
+ public override string Identifier => Type;
+ public override string NotificationType => Type;
+ }
+
+ public class PluginVersionCheckerDataHolder
+ {
+ public Dictionary<string, Version> LastVersions { get; set; }
+ }
+
+ public class PluginUpdateFetcher(SettingsRepository settingsRepository, NotificationSender notificationSender, PluginService pluginService)
+ : IPeriodicTask
+ {
+ public async Task Do(CancellationToken cancellationToken)
+ {
+ var dh = await settingsRepository.GetSettingAsync<PluginVersionCheckerDataHolder>() ??
+ new PluginVersionCheckerDataHolder();
+ dh.LastVersions = NormalizeVersions(dh.LastVersions);
+ var disabledPlugins = NormalizeVersions(pluginService.GetDisabledPlugins());
+
+ var installedPlugins = pluginService.Installed;
+ var remotePlugins = await pluginService.GetRemotePlugins(null, cancellationToken);
+ var latestRemotePlugins = remotePlugins
+ .GroupBy(plugin => plugin.Identifier, StringComparer.OrdinalIgnoreCase)
+ .Select(group => group.OrderByDescending(plugin => plugin.Version).First())
+ .Where(pair => installedPlugins.ContainsKey(pair.Identifier) || disabledPlugins.ContainsKey(pair.Identifier))
+ .ToArray();
+ foreach (var plugin in latestRemotePlugins)
+ {
+ if (dh.LastVersions.TryGetValue(plugin.Identifier, out var lastVersion) && lastVersion >= plugin.Version)
+ continue;
+
+ var hasUpdate = installedPlugins.TryGetValue(plugin.Identifier, out var installedVersion)
+ ? installedVersion < plugin.Version
+ : disabledPlugins.TryGetValue(plugin.Identifier, out var disabledVersion) && disabledVersion < plugin.Version;
+ if (hasUpdate)
+ await notificationSender.SendNotification(new AdminScope(), new PluginUpdateNotification(plugin));
+ }
+
+ dh.LastVersions = latestRemotePlugins.ToDictionary(
+ plugin => plugin.Identifier,
+ plugin => plugin.Version,
+ StringComparer.OrdinalIgnoreCase);
+
+ await settingsRepository.UpdateSetting(dh);
+ }
+
+ private static Dictionary<string, Version> NormalizeVersions(Dictionary<string, Version> versions)
+ {
+ return (versions ?? new Dictionary<string, Version>())
+ .GroupBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase)
+ .ToDictionary(
+ group => group.First().Key,
+ group => group.OrderByDescending(pair => pair.Value).First().Value,
+ StringComparer.OrdinalIgnoreCase);
+ }
+ }
+}
diff --git a/BTCPayServer/Plugins/PluginManager/Views/ListPlugins.cshtml b/BTCPayServer/Plugins/PluginManager/Views/ListPlugins.cshtml
new file mode 100644
index 0000000..4dd6917
--- /dev/null
+++ b/BTCPayServer/Plugins/PluginManager/Views/ListPlugins.cshtml
@@ -0,0 +1,292 @@
+@model InstalledPluginsViewModel
+@{
+ ViewData.SetTitle(StringLocalizer["Installed Plugins"]);
+}
+
+<style>
+ .version-switch .nav-link { display: inline; }
+ .version-switch .nav-link.active { display: none; }
+</style>
+
+<partial name="_StatusMessage" />
+
+@if (Model.PendingActions.Any())
+{
+ <div class="alert alert-info mb-4 d-flex align-items-center justify-content-between">
+ <span text-translate="true">You need to restart BTCPay Server in order to update your active plugins.</span>
+ <form method="post" asp-area="" asp-controller="UIServer" asp-action="Maintenance" class="mt-2">
+ <button type="submit" name="command" value="soft-restart" class="btn btn-info" text-translate="true">Restart now</button>
+ </form>
+ </div>
+}
+
+@if (Model.DisabledPlugins.Any())
+{
+ <div class="alert alert-danger mb-4 d-flex align-items-center justify-content-between">
+ <span text-translate="true">Some plugins were disabled due to fatal errors. They may be incompatible with this version of BTCPay Server.</span>
+ <form method="post" asp-action="UnInstallAllDisabledPlugin" class="mt-2">
+ <button type="submit" class="btn btn-danger" text-translate="true">Uninstall all disabled plugins</button>
+ </form>
+ </div>
+}
+
+<div class="d-flex flex-column gap-5">
+@if (Model.DisabledPlugins.Any())
+{
+ <section>
+ <h3 class="mb-4" text-translate="true">Disabled Plugins</h3>
+ <ul class="list-group list-group-flush d-inline-block">
+ @foreach (var plugin in Model.DisabledPlugins)
+ {
+ <li id="@plugin.Identifier" class="list-group-item px-0">
+ <div class="d-flex flex-wrap align-items-center justify-content-between gap-3">
+ <span>
+ @plugin.Identifier
+ @if (plugin.DisabledVersion is not null)
+ {
+ <span>(@plugin.DisabledVersion)</span>
+ }
+ @if (plugin.RecommendedUpdateVersion is not null)
+ {
+ <span class="badge bg-info ms-2">@plugin.RecommendedUpdateVersion <span text-translate="true">available</span></span>
+ }
+ </span>
+ <div class="d-flex flex-wrap gap-2">
+ @foreach (var action in plugin.Actions)
+ {
+ if (string.IsNullOrEmpty(action.FormAction))
+ {
+ <button type="button" class="@action.CssClass" disabled title="@(string.IsNullOrEmpty(action.Tooltip) ? null : StringLocalizer[action.Tooltip].Value)">@StringLocalizer[action.Label]</button>
+ }
+ else
+ {
+ <form method="post" asp-action="@action.FormAction">
+ <input type="hidden" name="plugin" value="@plugin.Identifier" />
+ @if (!string.IsNullOrEmpty(action.Version))
+ {
+ <input type="hidden" name="version" value="@action.Version" />
+ }
+ <button type="submit" class="@action.CssClass" title="@(string.IsNullOrEmpty(action.Tooltip) ? null : StringLocalizer[action.Tooltip].Value)">@StringLocalizer[action.Label]</button>
+ </form>
+ }
+ }
+ </div>
+ </div>
+ </li>
+ }
+ </ul>
+ </section>
+}
+
+@if (Model.InstalledPlugins.Any())
+{
+ <section>
+ <h3 class="mb-4" text-translate="true">Installed Plugins</h3>
+ <div class="row mb-4">
+ @foreach (var plugin in Model.InstalledPlugins)
+ {
+ var updateAvailable = plugin.Update is not null;
+ var tabId = plugin.Current.Identifier.ToLowerInvariant().Replace(".", "_");
+ <div class="col-12 col-md-6 col-lg-12 col-xl-6 col-xxl-4 mb-4">
+ <div class="card h-100" id="@plugin.Current.Identifier">
+ <div class="card-body">
+ <div class="d-flex flex-wrap align-items-baseline justify-content-between gap-2 mb-3">
+ <h4 class="card-title mb-0" data-bs-toggle="tooltip" title="@plugin.Current.Identifier">@plugin.Current.Name</h4>
+ @if (!string.IsNullOrEmpty(plugin.Current.Author))
+ {
+ <span class="text-muted text-nowrap">
+ <span text-translate="true">by</span>
+ @if (!string.IsNullOrEmpty(plugin.Current.AuthorLink))
+ {
+ <a href="@plugin.Current.AuthorLink" rel="noreferrer noopener" target="_blank">@plugin.Current.Author</a>
+ }
+ else
+ {
+ <span>@plugin.Current.Author</span>
+ }
+ </span>
+ }
+ </div>
+ <div class="d-flex flex-wrap align-items-center mb-2 gap-3">
+ <h5 class="text-muted d-flex align-items-center mt-1 gap-3">
+ @plugin.Current.Version
+ @if (updateAvailable)
+ {
+ <div class="badge bg-info">
+ @plugin.Update.Version <span text-translate="true">available</span>
+ </div>
+ }
+ </h5>
+ @if (updateAvailable)
+ {
+ <span class="nav version-switch mt-n1" role="tablist">
+ <a data-bs-toggle="tab" href="#@tabId-current" class="nav-link text-info p-0 active" text-translate="true">Show current info</a>
+ <a data-bs-toggle="tab" href="#@tabId-update" class="nav-link text-info p-0" text-translate="true">Show update info</a>
+ </span>
+ }
+ @if (!string.IsNullOrEmpty(plugin.PendingAction))
+ {
+ <span class="badge bg-light text-dark">@plugin.PendingAction</span>
+ }
+ </div>
+
+ <div class="tab-content">
+ <div class="tab-pane active" id="@tabId-current">
+ <p class="card-text">@plugin.Current.Description</p>
+ @if (plugin.Current.Dependencies.Any())
+ {
+ <h6 class="text-muted fw-semibold" text-translate="true">Dependencies</h6>
+ <ul class="list-group list-group-flush">
+ @foreach (var dependency in plugin.Current.Dependencies)
+ {
+ <li class="list-group-item p-2 d-inline-flex align-items-center gap-2">
+ @dependency.Display
+ @if (!dependency.IsMet)
+ {
+ <span title="@StringLocalizer["Dependency not met."]" data-bs-toggle="tooltip" class="text-danger">
+ <vc:icon symbol="warning" />
+ </span>
+ }
+ </li>
+ }
+ </ul>
+ }
+ </div>
+
+ @if (updateAvailable)
+ {
+ <div class="tab-pane" id="@tabId-update">
+ <p class="card-text">@plugin.Update.Description</p>
+ @if (plugin.Update.Dependencies.Any())
+ {
+ <h6 class="text-muted fw-semibold" text-translate="true">Dependencies</h6>
+ <ul class="list-group list-group-flush">
+ @foreach (var dependency in plugin.Update.Dependencies)
+ {
+ <li class="list-group-item p-2 d-inline-flex align-items-center gap-2">
+ @dependency.Display
+ @if (!dependency.IsMet)
+ {
+ <span title="@StringLocalizer["Dependency not met."]" data-bs-toggle="tooltip" class="text-danger">
+ <vc:icon symbol="warning" />
+ </span>
+ }
+ </li>
+ }
+ </ul>
+ }
+ </div>
+ }
+
+ <h6 class="text-muted fw-semibold mt-4" text-translate="true">Resources</h6>
+ <ul class="list-group list-group-flush list-unstyled">
+ @if (!string.IsNullOrEmpty(plugin.Current.Source))
+ {
+ <li>
+ <a href="@plugin.Current.Source" rel="noreferrer noopener" class="d-flex align-items-center gap-2" target="_blank">
+ <vc:icon symbol="social-github" />
+ <span text-translate="true">Sources</span>
+ </a>
+ </li>
+ }
+ @if (!string.IsNullOrEmpty(plugin.Current.Documentation))
+ {
+ <li>
+ <a href="@plugin.Current.Documentation" rel="noreferrer noopener" class="d-flex align-items-center gap-2" target="_blank">
+ <vc:icon symbol="info" css-class="text-primary" />
+ <span text-translate="true">Details</span>
+ </a>
+ </li>
+ }
+ else
+ {
+ <li>
+ <span class="d-flex align-items-center gap-2 text-danger">
+ <vc:icon symbol="docs" />
+ <span text-translate="true">No documentation</span>
+ </span>
+ </li>
+ }
+ </ul>
+ </div>
+ </div>
+ <div class="card-footer border-0 pb-3 d-flex flex-wrap gap-2">
+ @foreach (var action in plugin.Actions)
+ {
+ if (string.IsNullOrEmpty(action.FormAction))
+ {
+ <button type="button" class="@action.CssClass" disabled title="@(string.IsNullOrEmpty(action.Tooltip) ? null : StringLocalizer[action.Tooltip].Value)">@StringLocalizer[action.Label]</button>
+ }
+ else
+ {
+ <form method="post" asp-action="@action.FormAction">
+ <input type="hidden" name="plugin" value="@plugin.Current.Identifier" />
+ @if (!string.IsNullOrEmpty(action.Version))
+ {
+ <input type="hidden" name="version" value="@action.Version" />
+ }
+ <button type="submit" class="@action.CssClass" title="@(string.IsNullOrEmpty(action.Tooltip) ? null : StringLocalizer[action.Tooltip].Value)">@StringLocalizer[action.Label]</button>
+ </form>
+ }
+ }
+ </div>
+ </div>
+ </div>
+ }
+ </div>
+ </section>
+}
+</div>
+
+<div class="mb-4">
+ <h3 class="mb-4" text-translate="true">Upload Plugin</h3>
+ <button class="btn btn-secondary mb-4" type="button" data-bs-toggle="collapse" data-bs-target="#manual-upload" text-translate="true">Upload Plugin</button>
+ <div class="row collapse" id="manual-upload">
+ <div class="col col-xl-6 mb-4">
+ <div class="card">
+ <div class="card-body">
+ <h4 class="card-title" text-translate="true">Add plugin manually</h4>
+ <div class="alert alert-warning my-3">
+ <h6 class="me-1" text-translate="true">This is an extremely dangerous operation!</h6>
+ <span text-translate="true">Only upload plugins from trusted sources.</span>
+ </div>
+ <form method="post" enctype="multipart/form-data" asp-action="UploadPlugin">
+ <input type="file" class="form-control mb-3" required name="files" accept=".btcpay" id="files">
+ <button class="btn btn-primary" type="submit" text-translate="true">Upload</button>
+ </form>
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
+
+@if (Model.PendingActions.Any())
+{
+ <div class="mb-4">
+ <h3 class="mb-4" text-translate="true">Pending Action</h3>
+ <button class="btn btn-secondary mb-4" type="button" data-bs-toggle="collapse" data-bs-target="#pending-actions" text-translate="true">Pending Actions</button>
+ <div class="row collapse" id="pending-actions">
+ <div class="col-12 col-lg-6 mb-4">
+ <div class="card">
+ <div class="card-body">
+ <h4 class="card-title" text-translate="true">Pending actions</h4>
+ <ul class="list-group list-group-flush">
+ @foreach (var pendingAction in Model.PendingActions)
+ {
+ <li class="list-group-item p-2">
+ <div class="d-flex flex-wrap align-items-center justify-content-between">
+ <span class="my-2 me-3">@pendingAction.Plugin</span>
+ <form method="post" asp-action="CancelPluginCommands">
+ <input type="hidden" name="plugin" value="@pendingAction.Plugin" />
+ <button type="submit" class="btn btn-outline-secondary">@StringLocalizer["Cancel pending {0}", pendingAction.Action]</button>
+ </form>
+ </div>
+ </li>
+ }
+ </ul>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+}
diff --git a/BTCPayServer/Plugins/PluginManager/Views/PluginDirectory.cshtml b/BTCPayServer/Plugins/PluginManager/Views/PluginDirectory.cshtml
new file mode 100644
index 0000000..548c63f
--- /dev/null
+++ b/BTCPayServer/Plugins/PluginManager/Views/PluginDirectory.cshtml
@@ -0,0 +1,142 @@
+@model PluginDirectoryViewModel
+@{
+ ViewData.SetTitle(StringLocalizer["Plugin Directory"]);
+}
+
+<style>
+ .plugin-manage-frame {
+ height: min(62vh, 680px);
+ min-height: 520px;
+ background: var(--btcpay-body-bg, #fff);
+ }
+
+ .plugin-manage-offcanvas {
+ --bs-offcanvas-width: min(380px, 92vw);
+ }
+
+ #selected-plugin-panel,
+ .plugin-manage-panel-details {
+ min-height: 0;
+ }
+
+ @@media (max-width: 991.98px) {
+ .plugin-manage-frame {
+ height: min(60vh, 600px);
+ min-height: 460px;
+ }
+
+ .plugin-manage-offcanvas {
+ --bs-offcanvas-width: min(100vw, 420px);
+ }
+ }
+</style>
+
+<partial name="_StatusMessage" />
+
+@if (Model.HasPendingActions)
+{
+ <div class="alert alert-info mb-4 d-flex align-items-center justify-content-between">
+ <span text-translate="true">You need to restart BTCPay Server in order to update your active plugins.</span>
+ <form method="post" asp-area="" asp-controller="UIServer" asp-action="Maintenance" class="mt-2">
+ <button type="submit" name="command" value="soft-restart" class="btn btn-info" text-translate="true">Restart now</button>
+ </form>
+ </div>
+}
+
+<div class="alert alert-warning alert-dismissible mb-4" role="alert">
+ <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="@StringLocalizer["Close"]">
+ <vc:icon symbol="close" />
+ </button>
+ <h5 class="alert-heading" text-translate="true">Important notice about plugins</h5>
+ <p class="mb-0">
+ <span text-translate="true">Plugins are developed by third parties. They need to be updated and maintained regularly in addition to BTCPay Server. Use plugins at your own risk.</span>
+ <a href="#warning-details" data-bs-toggle="collapse" class="alert-link" text-translate="true">Read more</a>
+ </p>
+ <div class="collapse" id="warning-details">
+ <p class="my-3"><strong text-translate="true">Use at Your Own Risk:</strong> <span text-translate="true">Plugins in this store are developed by independent third parties. These plugins have not undergone review by the BTCPay Server team.</span></p>
+ <p class="mb-3"><strong text-translate="true">Disclaimer of Responsibility:</strong> <span text-translate="true">BTCPay Server contributors or Foundation are not liable for any harm, loss, or damage resulting from the installation or use of the plugins. Users assume full responsibility for their installation, use, familiarity with licensing and terms of service and maintenance.</span></p>
+ <p class="mb-3"><strong text-translate="true">No Official Endorsement:</strong> <span text-translate="true">Inclusion in the list of BTCPay Server plugins does not constitute an endorsement or guarantee of quality, safety, or compatibility.</span></p>
+ <p class="mb-3"><strong text-translate="true">Due Diligence Advised:</strong> <span text-translate="true">We recommend users exercise caution and conduct their own research or consult the community before installing any plugin.</span></p>
+ <p class="mb-0"><strong text-translate="true">Feedback and Reporting:</strong> <span text-translate="true">Should you experience issues with a plugin, please provide feedback or report concerns directly to the respective plugin developers.</span></p>
+ </div>
+</div>
+
+<section>
+ <div class="mb-4">
+ <h3 class="mb-2" text-translate="true">Available Plugins</h3>
+ <p class="text-muted mb-0" text-translate="true">Browse the public plugin directory here and install new plugins.</p>
+ </div>
+
+ <div id="plugins-embed-error-alert" class="alert alert-warning d-none mb-4" role="alert">
+ <div class="d-flex flex-wrap align-items-center gap-3">
+ <span class="me-auto" text-translate="true">BTCPay Server could not load the plugin directory.</span>
+ <button type="button" id="plugins-embed-retry" class="btn btn-outline-dark btn-sm" text-translate="true">Retry</button>
+ </div>
+ </div>
+ <div id="plugins-embed-shell"
+ data-iframe-url="@Model.DirectoryIframeUrl"
+ data-panel-url="@Url.Action("SelectedPluginPanel")"
+ data-selected-slug="@Model.SelectedPluginPanel.SelectedSlug"
+ data-panel-error-message="@StringLocalizer["BTCPay Server could not load the plugin panel."]"
+ data-panel-loading-message="@StringLocalizer["Loading..."]">
+ <script type="application/json" id="plugins-embed-hidden-plugin-identifiers">@Safe.Json(Model.HiddenPluginIdentifiers)</script>
+ <iframe id="plugins-directory-iframe"
+ title="@StringLocalizer["Plugin Directory"]"
+ class="plugin-manage-frame d-block w-100 border-0"
+ sandbox="allow-scripts allow-forms allow-popups"></iframe>
+ </div>
+ <div class="offcanvas offcanvas-end shadow plugin-manage-offcanvas"
+ tabindex="-1"
+ id="selected-plugin-offcanvas"
+ data-bs-scroll="true"
+ aria-labelledby="selected-plugin-offcanvas-label">
+ <div class="offcanvas-header justify-content-end px-3 pt-3 pb-0">
+ <h5 class="offcanvas-title visually-hidden" id="selected-plugin-offcanvas-label" text-translate="true">Manage plugin</h5>
+ <button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="@StringLocalizer["Close"]"></button>
+ </div>
+ <div class="offcanvas-body d-flex flex-column overflow-hidden px-4 pt-0 pb-4">
+ <div id="selected-plugin-panel" class="d-flex flex-column flex-grow-1 gap-3 overflow-hidden">
+ <partial name="_SelectedPluginPanel" model="Model.SelectedPluginPanel" />
+ </div>
+ </div>
+ </div>
+ <div class="modal fade" id="plugin-install-confirm-modal" tabindex="-1" aria-labelledby="plugin-install-confirm-title" aria-hidden="true">
+ <div class="modal-dialog modal-dialog-centered">
+ <form method="post" asp-action="InstallPlugin" class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title" id="plugin-install-confirm-title" text-translate="true">Install plugin</h5>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="@StringLocalizer["Close"]"></button>
+ </div>
+ <div class="modal-body">
+ <dl class="mb-0">
+ <dt class="text-muted small fw-semibold text-uppercase" text-translate="true">Plugin</dt>
+ <dd id="plugin-install-confirm-name" class="mb-2"></dd>
+ <dt class="text-muted small fw-semibold text-uppercase" text-translate="true">Identifier</dt>
+ <dd id="plugin-install-confirm-identifier" class="font-monospace small text-break mb-2"></dd>
+ <dt class="text-muted small fw-semibold text-uppercase" text-translate="true">Version</dt>
+ <dd id="plugin-install-confirm-version" class="fw-semibold mb-0"></dd>
+ </dl>
+ <p id="plugin-install-confirm-pending" class="alert alert-info mt-3 mb-0 d-none">
+ <span text-translate="true">A plugin install is already queued. Confirming will update the queued install to this version.</span>
+ <span class="d-block mt-1">
+ <span id="plugin-install-confirm-pending-version"></span>
+ <span aria-hidden="true">→</span>
+ <span id="plugin-install-confirm-pending-requested-version"></span>
+ </span>
+ </p>
+ <input type="hidden" name="plugin" />
+ <input type="hidden" name="version" />
+ <input type="hidden" name="returnTo" value="directory" />
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal" text-translate="true">Cancel</button>
+ <button type="submit" class="btn btn-primary" text-translate="true">Install</button>
+ </div>
+ </form>
+ </div>
+ </div>
+</section>
+
+@section PageFootContent {
+ <script src="~/plugins/PluginManager/plugins-embed.js" asp-append-version="true"></script>
+}
diff --git a/BTCPayServer/Plugins/PluginManager/Views/_SelectedPluginPanel.cshtml b/BTCPayServer/Plugins/PluginManager/Views/_SelectedPluginPanel.cshtml
new file mode 100644
index 0000000..3eafb5d
--- /dev/null
+++ b/BTCPayServer/Plugins/PluginManager/Views/_SelectedPluginPanel.cshtml
@@ -0,0 +1,67 @@
+@model PluginSelectedPanelViewModel
+@{
+ var showFacts = Model.InstalledVersion is not null ||
+ Model.DisabledVersion is not null ||
+ !string.IsNullOrEmpty(Model.PendingAction);
+ var allowPendingInstallVersionChange = Model.InstalledVersion is null &&
+ Model.DisabledVersion is null &&
+ string.Equals(Model.PendingAction, "install", StringComparison.OrdinalIgnoreCase);
+ var canInstallFromEmbed = Model.InstallVersion is not null || allowPendingInstallVersionChange;
+}
+
+@if (!string.IsNullOrEmpty(Model.SelectedSlug))
+{
+ @if (showFacts)
+ {
+ <div class="border-top border-bottom">
+ @if (Model.InstalledVersion is not null)
+ {
+ <div class="d-flex align-items-baseline justify-content-between gap-3 py-2">
+ <span class="text-muted small fw-semibold text-uppercase" text-translate="true">Installed version</span>
+ <span class="text-end fw-semibold">@Model.InstalledVersion</span>
+ </div>
+ }
+ @if (Model.DisabledVersion is not null)
+ {
+ <div class="d-flex align-items-baseline justify-content-between gap-3 py-2">
+ <span class="text-muted small fw-semibold text-uppercase" text-translate="true">Disabled version</span>
+ <span class="text-end fw-semibold">@Model.DisabledVersion</span>
+ </div>
+ }
+ @if (!string.IsNullOrEmpty(Model.PendingAction))
+ {
+ <div class="d-flex align-items-baseline justify-content-between gap-3 py-2">
+ <span class="text-muted small fw-semibold text-uppercase" text-translate="true">Pending Action</span>
+ <span class="text-end fw-semibold">
+ @Model.PendingAction
+ @if (Model.PendingVersion is not null)
+ {
+ <span>@Model.PendingVersion</span>
+ }
+ </span>
+ </div>
+ }
+ </div>
+ }
+
+ <iframe class="plugin-manage-panel-details d-block flex-grow-1 w-100 border-0 mt-1"
+ title="@StringLocalizer["{0} details", Model.PluginName]"
+ src="@Model.EmbeddedDetailsUrl"
+ sandbox="allow-scripts allow-forms allow-popups"
+ data-plugin-identifier="@(canInstallFromEmbed ? Model.PluginIdentifier : null)"
+ data-plugin-name="@(canInstallFromEmbed ? Model.PluginName : null)"
+ data-pending-install="@(allowPendingInstallVersionChange ? "true" : null)"
+ data-pending-install-version="@(allowPendingInstallVersionChange ? Model.PendingVersion : null)"></iframe>
+
+ @if (!string.IsNullOrEmpty(Model.PendingAction))
+ {
+ <div class="pt-3 border-top">
+ <form method="post"
+ asp-action="CancelPluginCommands"
+ asp-route-selectedSlug="@Model.SelectedSlug">
+ <input type="hidden" name="plugin" value="@Model.PluginIdentifier" />
+ <button type="submit" class="btn btn-outline-secondary w-100">@StringLocalizer["Cancel pending {0}", $"{Model.PendingAction}{(Model.PendingVersion is null ? string.Empty : $" of {Model.PendingVersion}")}"]</button>
+ </form>
+ </div>
+ }
+}
diff --git a/BTCPayServer/Plugins/PluginManager/Views/_ViewImports.cshtml b/BTCPayServer/Plugins/PluginManager/Views/_ViewImports.cshtml
new file mode 100644
index 0000000..33dd2a7
--- /dev/null
+++ b/BTCPayServer/Plugins/PluginManager/Views/_ViewImports.cshtml
@@ -0,0 +1,2 @@
+@using BTCPayServer.Plugins.PluginManagement.Models
+@namespace BTCPayServer.Plugins.PluginManagement.Views
diff --git a/BTCPayServer/Plugins/PluginManager/Views/_ViewStart.cshtml b/BTCPayServer/Plugins/PluginManager/Views/_ViewStart.cshtml
new file mode 100644
index 0000000..820a2f6
--- /dev/null
+++ b/BTCPayServer/Plugins/PluginManager/Views/_ViewStart.cshtml
@@ -0,0 +1,3 @@
+@{
+ Layout = "_Layout";
+}
diff --git a/BTCPayServer/Plugins/PluginService.cs b/BTCPayServer/Plugins/PluginService.cs
deleted file mode 100644
index 3e85987..0000000
--- a/BTCPayServer/Plugins/PluginService.cs
+++ /dev/null
@@ -1,244 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using BTCPayServer.Abstractions.Contracts;
-using BTCPayServer.Configuration;
-using BTCPayServer.Services;
-using Microsoft.AspNetCore.Builder;
-using Microsoft.AspNetCore.Http;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Options;
-using Newtonsoft.Json;
-using Newtonsoft.Json.Linq;
-
-namespace BTCPayServer.Plugins
-{
- public class PluginService
- {
- private readonly IOptions<DataDirectories> _dataDirectories;
- private readonly PoliciesSettings _policiesSettings;
- private readonly PluginBuilderClient _pluginBuilderClient;
-
- public PluginService(
- IEnumerable<IBTCPayServerPlugin> btcPayServerPlugins,
- PluginBuilderClient pluginBuilderClient,
- IOptions<DataDirectories> dataDirectories,
- PoliciesSettings policiesSettings,
- BTCPayServerEnvironment env
- )
- {
- LoadedPlugins = btcPayServerPlugins;
- Installed = btcPayServerPlugins.ToDictionary(p => p.Identifier, p => p.Version, StringComparer.OrdinalIgnoreCase);
- _pluginBuilderClient = pluginBuilderClient;
- _dataDirectories = dataDirectories;
- _policiesSettings = policiesSettings;
- Env = env;
- }
-
- public Dictionary<string, Version> Installed { get; set; }
-
- public IEnumerable<IBTCPayServerPlugin> LoadedPlugins { get; }
- public BTCPayServerEnvironment Env { get; }
-
- public Version GetVersionOfPendingInstall(string plugin)
- {
- var dirName = Path.Combine(_dataDirectories.Value.PluginDir, plugin);
- var manifestFileName = dirName + ".json";
- if (!File.Exists(manifestFileName)) return null;
- var pluginManifest = JObject.Parse(File.ReadAllText(manifestFileName)).ToObject<AvailablePlugin>();
- return pluginManifest.Version;
- }
-
- private string GetShortBtcpayVersion() => Env.Version.TrimStart('v').Split('+')[0];
-
- public async Task<AvailablePlugin[]> GetRemotePlugins(string searchPluginName, CancellationToken cancellationToken = default)
- {
- string btcpayVersion = GetShortBtcpayVersion();
- var versions = await _pluginBuilderClient.GetPublishedVersions(
- btcpayVersion, _policiesSettings.PluginPreReleases, searchPluginName, cancellationToken: cancellationToken);
-
- var plugins = versions
- .Select(MapToAvailablePlugin)
- .Where(p => p is not null)
- .Select(p => p!)
- .ToList();
-
- var listedIds = new HashSet<string>(
- plugins.Select(p => p.Identifier),
- StringComparer.OrdinalIgnoreCase);
-
- var loadedToCheck = LoadedPlugins
- .Where(p => !p.SystemPlugin && !listedIds.Contains(p.Identifier))
- .Select(p => new InstalledPluginRequest(p.Identifier, p.Version.ToString()))
- .ToList();
-
- if (loadedToCheck.Count <= 0) return plugins.ToArray();
-
- var updates = await _pluginBuilderClient.GetInstalledPluginsUpdates(
- btcpayVersion,
- _policiesSettings.PluginPreReleases,
- loadedToCheck, cancellationToken: cancellationToken);
-
- if (updates is { Length: > 0 })
- {
- plugins.AddRange(
- updates.Select(MapToAvailablePlugin)
- .Where(p => p is not null)
- .Select(p => p!)
- );
- }
-
- return plugins.ToArray();
- }
-
-#nullable enable
- private AvailablePlugin? MapToAvailablePlugin(PublishedVersion publishedVersion)
- {
- if (publishedVersion.ManifestInfo is null)
- return null;
-
- var availablePlugin = publishedVersion.ManifestInfo.ToObject<AvailablePlugin>();
- if (availablePlugin is null)
- throw new InvalidDataException($"Manifest deserialized to null BuildId: {publishedVersion.BuildId} PluginSlug: {publishedVersion.ProjectSlug}");
-
- availablePlugin.Documentation = publishedVersion.Documentation;
- var buildInfo = publishedVersion.BuildInfo;
- var github = buildInfo?.GetGithubRepository();
- if (buildInfo is not null && github is not null)
- {
- availablePlugin.Source = github.GetSourceUrl(buildInfo.gitCommit, buildInfo.pluginDir);
- availablePlugin.Author = github.Owner;
- availablePlugin.AuthorLink = $"https://github.com/{github.Owner}";
- }
- availablePlugin.SystemPlugin = false;
- return availablePlugin;
- }
-#nullable restore
-
- public async Task<AvailablePlugin> DownloadRemotePlugin(string pluginIdentifier, string version, VersionCondition condition = null)
- {
- if (version is null)
- {
- string btcpayVersion = GetShortBtcpayVersion();
- var versions = await _pluginBuilderClient.GetPluginVersionsForDownload(pluginIdentifier,
- btcpayVersion, _policiesSettings.PluginPreReleases, includeAllVersions: true);
- var potentialVersions = versions
- .Select(v => v.ManifestInfo?.ToObject<AvailablePlugin>())
- .Where(v => v is not null)
- .Where(v => v.Identifier == pluginIdentifier)
- .Select(v => v.Version)
- .ToList();
- if (potentialVersions.Count == 0)
- {
- throw new InvalidOperationException($"Plugin {pluginIdentifier} not found");
- }
-
- if (condition is not null)
- {
- version = potentialVersions
- .OrderDescending()
- .FirstOrDefault(condition.IsFulfilled)?.ToString();
- if (version is null)
- {
- throw new InvalidOperationException($"No version of plugin {pluginIdentifier} can satisfy condition {condition}");
- }
- }
- }
-
- var dest = _dataDirectories.Value.PluginDir;
- var filedest = Path.Join(dest, pluginIdentifier + ".btcpay");
- var filemanifestdest = Path.Join(dest, pluginIdentifier + ".json");
- var pluginSelector = $"[{Uri.EscapeDataString(pluginIdentifier)}]";
- version = Uri.EscapeDataString(version);
- Directory.CreateDirectory(Path.GetDirectoryName(filedest));
- var url = $"api/v1/plugins/{pluginSelector}/versions/{version}/download";
- var manifest = (await _pluginBuilderClient.GetPlugin(pluginSelector, version))?.ManifestInfo?.ToObject<AvailablePlugin>();
- await File.WriteAllTextAsync(filemanifestdest, JsonConvert.SerializeObject(manifest, Formatting.Indented));
- using var resp2 = await _pluginBuilderClient.HttpClient.GetAsync(url);
- await using var fs = new FileStream(filedest, FileMode.Create, FileAccess.ReadWrite);
- await resp2.Content.CopyToAsync(fs);
- await fs.FlushAsync();
- return manifest;
- }
-
- public void InstallPlugin(string plugin)
- {
- PluginManager.QueueCommands(_dataDirectories.Value.PluginDir, ("install", plugin));
- }
-
- public async Task UploadPlugin(IFormFile plugin)
- {
- var dest = _dataDirectories.Value.PluginDir;
- var filedest = Path.Combine(dest, plugin.FileName);
- Directory.CreateDirectory(Path.GetDirectoryName(filedest));
- if (Path.GetExtension(filedest) == PluginManager.BTCPayPluginSuffix)
- {
- await using var stream = new FileStream(filedest, FileMode.Create);
- await plugin.CopyToAsync(stream);
- }
- }
-
- public void UninstallPlugin(string plugin)
- {
- var dest = _dataDirectories.Value.PluginDir;
- PluginManager.CancelCommands(dest, plugin);
- PluginManager.QueueCommands(dest, ("delete", plugin));
- }
-
- public void EnablePlugin(string plugin){
- PluginManager.QueueCommands(_dataDirectories.Value.PluginDir, ("enable", plugin));
- }
-
- public class AvailablePlugin
- {
- 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 IBTCPayServerPlugin.PluginDependency[] Dependencies { get; set; } = Array.Empty<IBTCPayServerPlugin.PluginDependency>();
- public string Documentation { get; set; }
- public string Source { get; set; }
- public string Author { get; set; }
- public string AuthorLink { 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("type")] public string Type { get; set; }
- [JsonProperty("path")] public string Path { get; set; }
-
- [JsonProperty("download_url")] public string DownloadUrl { get; set; }
- }
-
- public (string command, string plugin)[] GetPendingCommands()
- {
- return PluginManager.GetPendingCommands(_dataDirectories.Value.PluginDir);
- }
-
- public void CancelCommands(string plugin)
- {
- PluginManager.CancelCommands(_dataDirectories.Value.PluginDir, plugin);
- }
-
- public Dictionary<string, Version> GetDisabledPlugins()
- {
- return PluginManager.GetDisabledPlugins(_dataDirectories.Value.PluginDir);
- }
- }
-}
diff --git a/BTCPayServer/Plugins/Translations/Translations.Default.cs b/BTCPayServer/Plugins/Translations/Translations.Default.cs
index 36cabcc..d4c0b1b 100644
--- a/BTCPayServer/Plugins/Translations/Translations.Default.cs
+++ b/BTCPayServer/Plugins/Translations/Translations.Default.cs
@@ -48,6 +48,7 @@ namespace BTCPayServer.Plugins.Translations
"{0} Wallet": "",
"{0} Wallet Labels": "",
"{0} Wallet Settings": "",
+ "{0} details": "",
"{0}-of-{1} {2} · {3}/{4} submitted · {5}": "",
"{0}-of-{1} {2} · {3}/{4} submitted · {5} · {6}": "",
"{0}/{1} signer keys submitted": "",
@@ -84,6 +85,7 @@ namespace BTCPayServer.Plugins.Translations
"A payment request with reference ID \"{0}\" already exists for this store.": "",
"A payment that was made to an approved payout by an external wallet is waiting for your confirmation.": "",
"A permission to the camera is needed to scan the QR code. Please grant the browser access and then retry.": "",
+ "A plugin install is already queued. Confirming will update the queued install to this version.": "",
"A Postgres compatible JSON Path (eg. $ ? (@.Customer.Name == \"John\"))": "",
"A self-hosted, open-source bitcoin payment processor.": "",
"Abort": "",
@@ -262,12 +264,16 @@ namespace BTCPayServer.Plugins.Translations
"Broadcast transaction": "",
"Browse all deposit addresses": "",
"Browse the invoices": "",
+ "Browse the public plugin directory here and install new plugins.": "",
"Browser connection": "",
"Browser Redirect": "",
"BTCPay exposes Core Lightning's REST service for outside consumption, you will find connection information here.": "",
"BTCPay is expecting you to access this website from <strong>": "",
+ "BTCPay Server contributors or Foundation are not liable for any harm, loss, or damage resulting from the installation or use of the plugins. Users assume full responsibility for their installation, use, familiarity with licensing and terms of service and maintenance.": "",
"BTCPay Server Configurator": "",
"BTCPay Server currently supports:": "",
+ "BTCPay Server could not load the plugin directory.": "",
+ "BTCPay Server could not load the plugin panel.": "",
"BTCPay Server Registration will redirect to a custom registration page": "",
"BTCPay Server sends email requests if email is enabled. You can also share this link or QR code with the selected signers.": "",
"BTCPay Server Supporters": "",
@@ -288,6 +294,7 @@ namespace BTCPayServer.Plugins.Translations
"Can use hot wallet": "",
"Cancel": "",
"Cancel Invoice": "",
+ "Cancel pending {0}": "",
"Cannot delete plan. It is currently in use by subscribers.": "",
"Cannot generate API keys while not using HTTPS or Tor": "",
"Card reset succeed": "",
@@ -564,6 +571,8 @@ namespace BTCPayServer.Plugins.Translations
"Disable zero amount invoices": "",
"Disabled": "",
"Disabled Plugins": "",
+ "Disabled version": "",
+ "Disclaimer of Responsibility:": "",
"Disabling will delete your rate script.": "",
"Discount": "",
"Discounts": "",
@@ -600,6 +609,7 @@ namespace BTCPayServer.Plugins.Translations
"Download a two-factor authenticator app like …": "",
"Download PSBT file": "",
"Downloaded language pack is invalid": "",
+ "Due Diligence Advised:": "",
"Dynamic": "",
"Dynamic DNS allows you to have a stable DNS name pointing to your server, even if your IP address changes regularly. This is recommended if you are hosting BTCPay Server at home and wish to have a clearnet domain to access your server.": "",
"Dynamic DNS Service": "",
@@ -739,6 +749,7 @@ namespace BTCPayServer.Plugins.Translations
"Files could not be added due to invalid names": "",
"Files uploaded, restart server to load plugins": "",
"Fill fake": "",
+ "Feedback and Reporting:": "",
"Fill out currency pair to test for (like {0})": "",
"Filter": "",
"Filter by address, label or date": "",
@@ -823,6 +834,7 @@ namespace BTCPayServer.Plugins.Translations
"I have written down my recovery phrase and stored it in a secure location": "",
"Id": "",
"ID": "",
+ "Identifier": "",
"If a translation isn’t available, it will be searched in the fallback.": "",
"If authorized, the generated API key will be provided to": "",
"If BTCPay Server shows you an invalid balance, {0}.<br />If some transactions appear in BTCPay Server, but are missing in another wallet, {1}.": "",
@@ -846,6 +858,7 @@ namespace BTCPayServer.Plugins.Translations
"Import your wallet file": "",
"Important notice about plugins": "",
"Impossible to fetch rate: {0}": "",
+ "Inclusion in the list of BTCPay Server plugins does not constitute an endorsement or guarantee of quality, safety, or compatibility.": "",
"In order to securely connect to your hardware wallet you must first download, install, and run the BTCPay Server Vault.": "",
"In order to upload, a {0} must be configured.": "",
"In use": "",
@@ -861,8 +874,10 @@ namespace BTCPayServer.Plugins.Translations
"Inputs": "",
"Insert your security device and proceed.": "",
"Install": "",
+ "Install plugin": "",
"Installed languages": "",
"Installed Plugins": "",
+ "Installed version": "",
"Instructions": "",
"Interval": "",
"Invalid": "",
@@ -1005,7 +1020,7 @@ namespace BTCPayServer.Plugins.Translations
"Manage billing": "",
"Manage labels": "",
"Manage Labels": "",
- "Manage Plugins": "",
+ "Manage plugin": "",
"Manual entry": "",
"Manually enter your 12 or 24 word recovery seed.": "",
"Map specific domains to specific apps": "",
@@ -1084,6 +1099,7 @@ namespace BTCPayServer.Plugins.Translations
"Next": "",
"NFC detected.": "",
"No": "",
+ "No Official Endorsement:": "",
"No access tokens yet.": "",
"No action needed": "",
"No claim made yet.": "",
@@ -1107,7 +1123,6 @@ namespace BTCPayServer.Plugins.Translations
"No payout selected": "",
"No Permission Usage Recorded": "",
"No permissions": "",
- "No plugins found": "",
"No policies": "",
"No public address available.": "",
"No public address has been configured.": "",
@@ -1314,8 +1329,10 @@ namespace BTCPayServer.Plugins.Translations
"Please wait for your node to be synched": "",
"Please, confirm on the device first...": "",
"Please, enter the passphrase on the device.": "",
+ "Plugin": "",
"Plugin action cancelled.": "",
"Plugin Directory": "",
+ "Plugins in this store are developed by independent third parties. These plugins have not undergone review by the BTCPay Server team.": "",
"Plugin scheduled to be enabled.": "",
"Plugin scheduled to be installed.": "",
"Plugin scheduled to be uninstalled.": "",
@@ -1522,9 +1539,9 @@ namespace BTCPayServer.Plugins.Translations
"Scan wallet QR with camera": "",
"Scan with camera": "",
"Scanning the UTXO set allow you to restore the balance of your wallet, but not all the transaction history. This operation will scan the HD Path <b>0/*</b>, <b>1/*</b> and <b>*</b> from a starting index, until no UTXO are found in a whole gap limit.": "",
- "Schedule install": "",
"Schedule transaction": "",
"Schedule update": "",
+ "Schedule upgrade for when the dependencies have been met to ensure a smooth update": "",
"Scheduled plan change cancelled.": "",
"Scheme": "",
"Scope": "",
@@ -1639,6 +1656,7 @@ namespace BTCPayServer.Plugins.Translations
"Show unconfirmed coins": "",
"Show update info": "",
"Showing {0} – {1} of {2}": "",
+ "Should you experience issues with a plugin, please provide feedback or report concerns directly to the respective plugin developers.": "",
"Showing {0} of {1}": "",
"Sign": "",
"Sign in": "",
@@ -1966,6 +1984,7 @@ namespace BTCPayServer.Plugins.Translations
"This payment method requires javascript.": "",
"This permission is not available for your account.": "",
"This process disables 2FA until you verify your authenticator app. If you do not complete your authenticator app configuration you may lose access to your account.": "",
+ "This plugin cannot be uninstalled as it is depended on by other plugins.": "",
"This processor cannot handle {0}.": "",
"This pull payment does not exists": "",
"This quarter": "",
@@ -2150,6 +2169,7 @@ namespace BTCPayServer.Plugins.Translations
"Username could not be removed": "",
"Username is already taken": "",
"Users": "",
+ "Use at Your Own Risk:": "",
"Uses the store's default currency ({0}) if empty.": "",
"Using an HD private key or mnemonic seed": "",
"Using apps such as Google or Microsoft Authenticator.": "",
@@ -2174,7 +2194,9 @@ namespace BTCPayServer.Plugins.Translations
"via HTTPS": "",
"via TCP or unix domain socket connection": "",
"via the REST API": "",
+ "Version": "",
"View": "",
+ "We recommend users exercise caution and conduct their own research or consult the community before installing any plugin.": "",
"View All": "",
"View all": "",
"View all supporters": "",
diff --git a/BTCPayServer/Views/UIServer/ListPlugins.cshtml b/BTCPayServer/Views/UIServer/ListPlugins.cshtml
deleted file mode 100644
index 7dc42a0..0000000
--- a/BTCPayServer/Views/UIServer/ListPlugins.cshtml
+++ /dev/null
@@ -1,592 +0,0 @@
-@using BTCPayServer.Configuration
-@using BTCPayServer.Plugins
-@model BTCPayServer.Controllers.UIServerController.ListPluginsViewModel
-@inject BTCPayServerOptions BTCPayServerOptions
-@inject PluginService PluginService
-@{
- Layout = "_Layout";
- ViewData.SetLayoutModel(new LayoutModel(nameof(ServerNavPages.Plugins), StringLocalizer["Plugins"]));
- var installed = Model.Installed;
- var availableAndNotInstalled = new List<PluginService.AvailablePlugin>();
- var availableAndNotInstalledx = Model.Available
- .Where(plugin => !installed.ContainsKey(plugin.Identifier))
- .GroupBy(plugin => plugin.Identifier)
- .ToList();
-
- foreach (var availableAndNotInstalledItem in availableAndNotInstalledx)
- {
- var ordered = availableAndNotInstalledItem.OrderByDescending(plugin => plugin.Version).ToArray();
- availableAndNotInstalled.Add(ordered.FirstOrDefault(availablePlugin => PluginManager.DependenciesMet(availablePlugin.Dependencies, installed)) ?? ordered.FirstOrDefault());
- }
-
- bool DependentOn(string plugin)
- {
- foreach (var installedPlugin in Model.Plugins)
- {
- if (installedPlugin.Dependencies.Any(dep => dep.Identifier.Equals(plugin, StringComparison.InvariantCultureIgnoreCase)))
- {
- return true;
- }
- }
-
- var pendingInstalls = Model.Commands.Where(tuple => tuple.command != "uninstall").Select(tuple => tuple.plugin).Distinct();
- foreach (var pendingInstall in pendingInstalls)
- {
- if (Model.Available.Any(availablePlugin => availablePlugin.Identifier.Equals(pendingInstall, StringComparison.InvariantCultureIgnoreCase) &&
- availablePlugin.Dependencies.Any(dep => dep.Identifier.Equals(plugin, StringComparison.InvariantCultureIgnoreCase))))
- {
- return true;
- }
- }
- return false;
- }
-}
-
-<style>
- .version-switch .nav-link { display: inline; }
- .version-switch .nav-link.active { display: none; }
-</style>
-
-<partial name="_StatusMessage" />
-
-<div class="alert alert-warning alert-dismissible mb-4" role="alert">
- <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="@StringLocalizer["Close"]">
- <vc:icon symbol="close" />
- </button>
- <h5 class="alert-heading" text-translate="true">Important notice about plugins</h5>
- <p class="mb-0">
- <span text-translate="true">Plugins are developed by third parties. They need to be updated and maintained regularly in addition to BTCPay Server. Use plugins at your own risk.</span>
- <a href="#warning-details" data-bs-toggle="collapse" class="alert-link" text-translate="true">Read more</a>
- </p>
- <div class="collapse" id="warning-details">
- <p class="my-3"><strong>Use at Your Own Risk:</strong> Plugins in this store are developed by independent third parties. These plugins have not undergone review by the BTCPay Server team.</p>
- <p class="mb-3"><strong>Disclaimer of Responsibility:</strong> BTCPay Server contributors or Foundation are not liable for any harm, loss, or damage resulting from the installation or use of the plugins. Users assume full responsibility for their installation, use, familiarity with licensing and terms of service and maintenance.</p>
- <p class="mb-3"><strong>No Official Endorsement:</strong> Inclusion in the list of BTCPay Server plugins does not constitute an endorsement or guarantee of quality, safety, or compatibility.</p>
- <p class="mb-3"><strong>Due Diligence Advised:</strong> We recommend users exercise caution and conduct their own research or consult the community before installing any plugin.</p>
- <p class="mb-0"><strong>Feedback and Reporting:</strong> Should you experience issues with a plugin, please provide feedback or report concerns directly to the respective plugin developers.</p>
- </div>
-</div>
-
-@if (Model.Commands.Any())
-{
- <div class="alert alert-info mb-4 d-flex align-items-center justify-content-between">
- <span text-translate="true">You need to restart BTCPay Server in order to update your active plugins.</span>
- @if (Model.CanShowRestart)
- {
- <form method="post" asp-action="Maintenance" class="mt-2">
- <button type="submit" name="command" value="soft-restart" class="btn btn-info" asp-action="Maintenance" text-translate="true">Restart now</button>
- </form>
- }
- </div>
-}
-@if (Model.Disabled.Any())
-{
- <div class="alert alert-danger mb-4 d-flex align-items-center justify-content-between">
- <span text-translate="true">Some plugins were disabled due to fatal errors. They may be incompatible with this version of BTCPay Server.</span>
- <form asp-action="UnInstallAllDisabledPlugin" class="mt-2">
- <button type="submit" name="command" value="soft-restart" class="btn btn-danger" text-translate="true">Uninstall all disabled plugins</button>
- </form>
- </div>
- <div class="mb-5">
- <h3 class="mb-4" text-translate="true">Disabled Plugins</h3>
- <ul class="list-group list-group-flush d-inline-block">
- @foreach (var (plugin, version) in Model.Disabled)
- {
- <li class="list-group-item px-0">
- <div class="d-flex flex-wrap align-items-center justify-content-between gap-3">
- <span>
- @plugin
- @if (version != null)
- {
- <span>(@version)</span>
- }
- </span>
- @{
- var uninstalled = Model.Commands.Any(c => c.plugin.Equals(plugin, StringComparison.InvariantCultureIgnoreCase) && c.command == "delete");
- var enabled = Model.Commands.Any(c => c.plugin.Equals(plugin, StringComparison.InvariantCultureIgnoreCase) && c.command == "enable");
- var pendingInstall = Model.Commands.Any(c => c.plugin.Equals(plugin, StringComparison.InvariantCultureIgnoreCase) && c.command == "install");
- Model.DisabledPluginUpdates.TryGetValue(plugin, out var recommendedUpdate);
- }
- <div class="d-flex gap-2">
- @if (pendingInstall)
- {
- <button type="button" class="btn btn-sm btn-outline-primary" disabled text-translate="true">Marked for update</button>
- }
- else if (recommendedUpdate != null && !uninstalled && !enabled)
- {
- <form asp-action="InstallPlugin" asp-route-plugin="@plugin" asp-route-version="@recommendedUpdate.Version">
- @if (PluginManager.DependenciesMet(recommendedUpdate.Dependencies, installed))
- {
- <button type="submit" class="btn btn-sm btn-primary" text-translate="true">Update</button>
- }
- else
- {
- <button title="Schedule upgrade for when the dependencies have been met to ensure a smooth update" data-bs-toggle="tooltip" type="submit" class="btn btn-sm btn-primary" text-translate="true">Schedule update</button>
- }
- </form>
- }
- <form asp-action="EnablePlugin" asp-route-plugin="@plugin">
- @if (enabled)
- {
- <button type="submit" class="btn btn-sm btn-outline-primary" disabled text-translate="true">Marked for enabling</button>
- }
- else if (!uninstalled && !pendingInstall)
- {
- <button type="submit" class="btn btn-sm btn-outline-primary" text-translate="true">Enable</button>
- }
- </form>
-
- <form asp-action="UnInstallPlugin" asp-route-plugin="@plugin">
- @if (uninstalled)
- {
- <button type="submit" class="btn btn-sm btn-outline-danger" disabled text-translate="true">Marked for deletion</button>
- }
- else if (!enabled && !pendingInstall)
- {
- <button type="submit" class="btn btn-sm btn-outline-danger" text-translate="true">Uninstall</button>
- }
- </form>
- </div>
- </div>
- </li>
- }
- </ul>
- </div>
-}
-
-<div id="plugins-installed"></div>
-@if (Model.Plugins.Any())
-{
- <h3 class="mb-4" text-translate="true">Installed Plugins</h3>
- <div class="row mb-4">
- @foreach (var plugin in Model.Plugins.Where(i => !i.SystemPlugin))
- {
- Model.DownloadedPluginsByIdentifier.TryGetValue(plugin.Identifier, out var downloadInfo);
- var matchedAvailable = Model.Available.Where(availablePlugin => availablePlugin.Identifier == plugin.Identifier && availablePlugin.Version > plugin.Version).OrderByDescending(availablePlugin => availablePlugin.Version).ToArray();
- var x = matchedAvailable.FirstOrDefault(availablePlugin => PluginManager.DependenciesMet(availablePlugin.Dependencies, installed)) ?? matchedAvailable.FirstOrDefault();
- var updateAvailable = matchedAvailable.Any();
- var tabId = plugin.Identifier.ToLowerInvariant().Replace(".", "_");
- <div class="col col-12 col-md-6 col-lg-12 col-xl-6 col-xxl-4 mb-4">
- <div class="card h-100" id="@plugin.Identifier">
- <div class="card-body">
- <div class="d-flex flex-wrap align-items-baseline justify-content-between gap-2 mb-3">
- <h4 class="card-title mb-0" data-bs-toggle="tooltip" title="@plugin.Identifier">@plugin.Name</h4>
- @if (!string.IsNullOrEmpty(downloadInfo?.Author))
- {
- <span class="text-muted text-nowrap">
- by
- <a href="@downloadInfo.AuthorLink" rel="noreferrer noopener" target="_blank">
- @downloadInfo.Author
- </a>
- </span>
- }
- </div>
- <div class="d-flex flex-wrap align-items-center mb-2 gap-3">
- <h5 class="text-muted d-flex align-items-center mt-1 gap-3">
- @plugin.Version
- @if (updateAvailable && x != null)
- {
- <div class="badge bg-info">
- @x.Version <span text-translate="true">available</span>
- </div>
- }
- </h5>
- @if (updateAvailable)
- {
- <span class="nav version-switch mt-n1" role="tablist">
- <a data-bs-toggle="tab" href="#@tabId-current" class="nav-link text-info p-0 show active" text-translate="true">Show current info</a>
- <a data-bs-toggle="tab" href="#@tabId-update" class="nav-link text-info p-0" text-translate="true">Show update info</a>
- </span>
- }
- </div>
-
- <div class="tab-content">
- <div class="tab-pane active" id="@tabId-current">
- <p class="card-text">@plugin.Description</p>
- @if (plugin.Dependencies.Any())
- {
- <h6 class="text-muted fw-semibold" text-translate="true">Dependencies</h6>
- <ul class="list-group list-group-flush">
- @foreach (var dependency in plugin.Dependencies)
- {
- <li class="list-group-item p-2 d-inline-flex align-items-center gap-2">
- @dependency
- @if (!PluginManager.DependencyMet(dependency, installed))
- {
- <span title="Dependency not met." data-bs-toggle="tooltip" class="text-danger">
- <vc:icon symbol="warning" />
- </span>
- }
- </li>
- }
- </ul>
- }
- </div>
-
- @if (updateAvailable && x != null)
- {
- <div class="tab-pane" id="@tabId-update">
- <p class="card-text">@x.Description</p>
- @if (x.Dependencies.Any())
- {
- <h6 class="text-muted fw-semibold" text-translate="true">Dependencies</h6>
- <ul class="list-group list-group-flush">
- @foreach (var dependency in x.Dependencies)
- {
- <li class="list-group-item p-2 d-inline-flex align-items-center gap-2">
- @dependency
- @if (!PluginManager.DependencyMet(dependency, installed))
- {
- <span title="Dependency not met." data-bs-toggle="tooltip" class="text-danger">
- <vc:icon symbol="warning" />
- </span>
- }
- </li>
- }
- </ul>
- }
- </div>
- }
-
- @if (plugin != null)
- {
- <h6 class="text-muted fw-semibold mt-4" text-translate="true">Resources</h6>
- <ul class="list-group list-group-flush list-unstyled">
- @if (downloadInfo?.Source is not null)
- {
- <li>
- <a href="@downloadInfo.Source" rel="noreferrer noopener" class="d-flex align-items-center" target="_blank">
- <vc:icon symbol="social-github" />
- <span style="margin-left:.4rem" text-translate="true">Sources</span>
- </a>
- </li>
- }
- @if (!string.IsNullOrEmpty(downloadInfo?.Documentation))
- {
- <li>
- <a href="@downloadInfo.Documentation" rel="noreferrer noopener" class="d-flex align-items-center gap-2" target="_blank">
- <vc:icon symbol="docs" />
- <span text-translate="true">Documentation</span>
- </a>
- </li>
- }
- else
- {
- <li>
- <span rel="noreferrer noopener" class="d-flex align-items-center gap-2 text-danger" target="_blank">
- <vc:icon symbol="docs" />
- <span text-translate="true">No documentation</span>
- </span>
- </li>
- }
- </ul>
- }
- </div>
- </div>
- @{
- var pendingAction = Model.Commands.LastOrDefault(tuple => tuple.plugin.Equals(plugin.Identifier, StringComparison.InvariantCultureIgnoreCase)).command;
- var exclusivePendingAction = true;
-
- var versionOfPendingInstall = PluginService.GetVersionOfPendingInstall(plugin.Identifier);
- }
- <div class="card-footer border-0 pb-3 d-flex gap-2">
- @if (pendingAction is not null && updateAvailable)
- {
- var isUpdateAction = Model.Commands.Last(tuple => tuple.plugin.Equals(plugin.Identifier, StringComparison.InvariantCultureIgnoreCase)).command == "update";
- if (isUpdateAction)
- {
- exclusivePendingAction = versionOfPendingInstall == x.Version;
- }
- }
- @if (pendingAction is not null)
- {
- <form asp-action="CancelPluginCommands" asp-route-plugin="@plugin.Identifier">
- <button type="submit" class="btn btn-outline-secondary">Cancel pending @pendingAction @(versionOfPendingInstall is null? "": $"of {versionOfPendingInstall}")</button>
- </form>
- }
- @if (pendingAction is null || !exclusivePendingAction)
- {
- @if (updateAvailable && x != null)
- {
- if (PluginManager.DependenciesMet(x.Dependencies, installed))
- {
- <form asp-action="InstallPlugin" asp-route-plugin="@plugin.Identifier" asp-route-version="@x.Version" class="me-3">
- <button type="submit" class="btn btn-secondary" text-translate="true">Update</button>
- </form>
- }
- else
- {
- <form asp-action="InstallPlugin" asp-route-plugin="@plugin.Identifier" asp-route-version="@x.Version" class="me-3">
- <button title="Schedule upgrade for when the dependencies have been met to ensure a smooth update" data-bs-toggle="tooltip" type="submit" class="btn btn-secondary" text-translate="true">Schedule update</button>
- </form>
- }
- }
-
-
- @if (DependentOn(plugin.Identifier))
- {
- <button type="button" class="btn btn-outline-danger d-flex align-items-center gap-3" data-bs-toggle="tooltip" title="This plugin cannot be uninstalled as it is depended on by other plugins.">
- <span text-translate="true">Uninstall</span>
- <vc:icon symbol="warning"/>
- </button>
- }
- else
- {
- <form asp-action="UnInstallPlugin" asp-route-plugin="@plugin.Identifier">
- <button type="submit" class="btn btn-outline-danger" text-translate="true">Uninstall</button>
- </form>
- }
- }
- </div>
- </div>
- </div>
- }
- </div>
-}
-
-<form method="get" asp-action="ListPlugins" class="row mb-4" id="searchForm">
- <div class="col-12 col-lg-6">
- <div class="input-group">
- <input type="text" name="search" class="form-control" placeholder="@StringLocalizer["Search…"]"
- value="@ViewContext.HttpContext.Request.Query["search"]" />
- <button type="submit" class="btn btn-primary">
- <vc:icon symbol="actions-search" />
- </button>
- </div>
- </div>
-</form>
-<script>
- // if we search using form, scroll to it
- document.addEventListener("DOMContentLoaded", function() {
- const searchForm = document.getElementById('searchForm');
-
- // Set session storage flag on form submit
- searchForm.addEventListener('submit', function() {
- sessionStorage.setItem('formSubmitted', 'true');
- });
-
- // Check session storage to see if form was submitted, and scroll + clear it
- if (sessionStorage.getItem('formSubmitted') === 'true') {
- sessionStorage.removeItem('formSubmitted');
- if (searchForm) {
- searchForm.scrollIntoView();
- }
- }
- });
-
-</script>
-
-<div id="plugins-directory"></div>
-@if (!availableAndNotInstalled.Any())
-{
- <div class="row mb-4">
- <div class="col mb-4" style="margin-left:10px;" text-translate="true">No plugins found</div>
- </div>
-}
-else
-{
- <h3 class="mb-4" text-translate="true">Available Plugins</h3>
- <div class="row mb-4">
- @foreach (var plugin in availableAndNotInstalled)
- {
- var recommended = BTCPayServerOptions.RecommendedPlugins.Any(id => string.Equals(id, plugin.Identifier, StringComparison.InvariantCultureIgnoreCase));
- Model.Disabled.TryGetValue(plugin.Identifier, out var disabled);
-
- <div class="col col-12 col-md-6 col-lg-12 col-xl-6 col-xxl-4 mb-4">
- <div class="card h-100" id="@plugin.Identifier">
- <div class="card-body">
- <div class="d-flex flex-wrap align-items-baseline justify-content-between gap-2 mb-3">
- <h4 class="card-title mb-0" data-bs-toggle="tooltip" title="@plugin.Identifier">@plugin.Name</h4>
- @if (!string.IsNullOrEmpty(plugin.Author))
- {
- <span class="text-muted text-nowrap">
- by
- <a href="@plugin.AuthorLink" rel="noreferrer noopener" target="_blank">
- @plugin.Author
- </a>
- </span>
- }
- </div>
- <h5 class="text-muted d-flex align-items-center mt-1 gap-2">
- @plugin.Version
- @if (disabled is { } && disabled != plugin.Version)
- {
- <div class="badge bg-light"><span text-translate="true">Disabled</span> (@disabled)</div>
- }
- else if (disabled is { } && disabled == plugin.Version)
- {
- <div class="badge bg-light" text-translate="true">Disabled</div>
- }
- else if (recommended)
- {
- <div class="badge bg-light text-nowrap" data-bs-toggle="tooltip" title="This plugin has been recommended to be installed by your deployment method.">
- <span text-translate="true">Recommended</span>
- <vc:icon symbol="info" />
- </div>
- }
- </h5>
- <p class="card-text">@plugin.Description</p>
- @if (plugin.Dependencies?.Any() is true)
- {
- <h6 class="text-muted fw-semibold" text-translate="true">Dependencies</h6>
- <ul class="list-group list-group-flush">
- @foreach (var dependency in plugin.Dependencies)
- {
- <li class="list-group-item p-2 d-inline-flex align-items-center gap-2">
- @dependency
- @if (!PluginManager.DependencyMet(dependency, installed))
- {
- <span title="Dependency not met." data-bs-toggle="tooltip" class="text-danger">
- <vc:icon symbol="warning" />
- </span>
- }
- </li>
- }
- </ul>
- if(!PluginManager.DependenciesMet(plugin.Dependencies, installed))
- {
- <div class="text-warning py-2" text-translate="true">
- Dependencies not met.
- </div>
- }
- }
-
- @if (plugin != null)
- {
- <h6 class="text-muted fw-semibold mt-4" text-translate="true">Resources</h6>
- <ul class="list-group list-group-flush list-unstyled">
- @if (plugin.Source is not null)
- {
- <li>
- <a href="@plugin.Source" rel="noreferrer noopener" class="d-flex align-items-center" target="_blank">
- <vc:icon symbol="social-github" />
- <span style="margin-left:.4rem" text-translate="true">Sources</span>
- </a>
- </li>
- }
- @if (!string.IsNullOrEmpty(plugin.Documentation))
- {
- <li>
- <a href="@plugin.Documentation" rel="noreferrer noopener" class="d-flex align-items-center gap-2" target="_blank">
- <vc:icon symbol="docs" />
- <span text-translate="true">Documentation</span>
- </a>
- </li>
- }
- else
- {
- <li>
- <span rel="noreferrer noopener" class="d-flex align-items-center gap-2 text-danger" target="_blank">
- <vc:icon symbol="docs" />
- <span text-translate="true">No documentation</span>
- </span>
- </li>
- }
- </ul>
- }
- </div>
- <div class="card-footer border-0 pb-3 d-flex gap-2">
- @{
- var res = Model.Commands.LastOrDefault(tuple => tuple.plugin.Equals(plugin.Identifier, StringComparison.InvariantCultureIgnoreCase));
- var pendingAction = res != default ? res.command : null;
- var versionOfPendingInstall = PluginService.GetVersionOfPendingInstall(plugin.Identifier);
- var exclusivePendingAction = pendingAction is not null && (pendingAction == "delete" || versionOfPendingInstall == plugin.Version);
- }
- @if (pendingAction is not null)
- {
- <form asp-action="CancelPluginCommands" asp-route-plugin="@plugin.Identifier">
- <button type="submit" class="btn btn-outline-secondary">Cancel pending @pendingAction @(versionOfPendingInstall is null? "": $"of {versionOfPendingInstall}")</button>
- </form>
- }
- @if (pendingAction is null|| !exclusivePendingAction)
- {
- if (PluginManager.DependenciesMet(plugin.Dependencies, installed))
- {
- @if (disabled is null)
- {
- <form asp-action="InstallPlugin" asp-route-plugin="@plugin.Identifier" asp-route-version="@plugin.Version">
- <button type="submit" class="btn btn-primary" text-translate="true">Install</button>
- </form>
- }
- else if (disabled != plugin.Version)
- {
- <form asp-action="InstallPlugin" asp-route-plugin="@plugin.Identifier" asp-route-version="@plugin.Version">
- <button type="submit" class="btn btn-primary" text-translate="true">Update</button>
- </form>
- }
- }
- else
- {
- <form asp-action="InstallPlugin" asp-route-plugin="@plugin.Identifier" asp-route-version="@plugin.Version">
- <button title="Schedule install for when the dependencies have been met to ensure a smooth update" data-bs-toggle="tooltip" type="submit" class="btn btn-primary" text-translate="true">Schedule install</button>
- </form>
- }
- }
- @if (disabled is not null && pendingAction is null)
- {
- <form asp-action="UnInstallPlugin" asp-route-plugin="@plugin.Identifier">
- <button type="submit" class="btn btn-sm btn-outline-danger" text-translate="true">Uninstall</button>
- </form>
- }
- </div>
- </div>
- </div>
- }
- </div>
-}
-
-<div class="mb-4">
- <h3 class="mb-4" text-translate="true">Upload Plugin</h3>
- <button class="btn btn-secondary mb-4" type="button" data-bs-toggle="collapse" data-bs-target="#manual-upload" text-translate="true">
- Upload Plugin
- </button>
- <div class="row collapse" id="manual-upload">
- <div class="col col-xl-6 mb-4">
- <div class="card">
- <div class="card-body">
- <h4 class="card-title" text-translate="true">Add plugin manually</h4>
- <div class="alert alert-warning my-3">
- <h6 class="me-1" text-translate="true">This is an extremely dangerous operation!</h6>
- <span text-translate="true">Only upload plugins from trusted sources.</span>
- </div>
- <form method="post" enctype="multipart/form-data" asp-action="UploadPlugin">
- <input type="file" class="form-control mb-3" required name="files" accept=".btcpay" id="files">
- <button class="btn btn-primary" type="submit" text-translate="true">Upload</button>
- </form>
- </div>
- </div>
- </div>
- </div>
-</div>
-
-@if (Model.Commands.Any())
-{
- <div class="mb-4">
- <h3 class="mb-4" text-translate="true">Pending Action</h3>
- <button class="btn btn-secondary mb-4" type="button" data-bs-toggle="collapse" data-bs-target="#pending-actions" text-translate="true">
- Pending Actions
- </button>
- <div class="row collapse" id="pending-actions">
- <div class="col col-12 col-lg-6 mb-4">
- <div class="card">
- <div class="card-body">
- <h4 class="card-title" text-translate="true">Pending actions</h4>
- <ul class="list-group list-group-flush">
- @foreach (var extComm in Model.Commands.GroupBy(tuple => tuple.plugin))
- {
- <li class="list-group-item p-2">
- <div class="d-flex flex-wrap align-items-center justify-content-between">
- <span class="my-2 me-3">@extComm.Key</span>
- <form asp-action="CancelPluginCommands" asp-route-plugin="@extComm.Key">
- <button type="submit" class="btn btn-outline-secondary">Cancel pending @extComm.Last().command</button>
- </form>
- </div>
- </li>
- }
- </ul>
- </div>
- </div>
- </div>
- </div>
- </div>
-}
diff --git a/BTCPayServer/Views/UIServer/ServerNavPages.cs b/BTCPayServer/Views/UIServer/ServerNavPages.cs
index 06d71d3..e62944b 100644
--- a/BTCPayServer/Views/UIServer/ServerNavPages.cs
+++ b/BTCPayServer/Views/UIServer/ServerNavPages.cs
@@ -2,7 +2,7 @@ namespace BTCPayServer.Views.Server
{
public enum ServerNavPages
{
- Users, Emails, Policies, Branding, Services, Maintenance, Logs, Files, Plugins,
+ Users, Emails, Policies, Branding, Services, Maintenance, Logs, Files,
Roles,
Stores,
Translations
diff --git a/BTCPayServer/wwwroot/img/icon-sprite.svg b/BTCPayServer/wwwroot/img/icon-sprite.svg
index 6150b8d..9056c9c 100644
--- a/BTCPayServer/wwwroot/img/icon-sprite.svg
+++ b/BTCPayServer/wwwroot/img/icon-sprite.svg
@@ -72,9 +72,7 @@
<symbol id="nav-pay-button" viewBox="0 0 24 24" fill="none"><path d="M8.30766 4.61523H15.6923C17.723 4.61523 19.3846 6.27677 19.3846 8.30754V15.6922C19.3846 17.7229 17.723 19.3845 15.6923 19.3845H8.30766C6.27689 19.3845 4.61536 17.7229 4.61536 15.6922V8.30754C4.61536 6.27677 6.27689 4.61523 8.30766 4.61523Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10"/><path d="M12 15.0777C13.6994 15.0777 15.0769 13.7001 15.0769 12.0008C15.0769 10.3014 13.6994 8.92383 12 8.92383C10.3007 8.92383 8.9231 10.3014 8.9231 12.0008C8.9231 13.7001 10.3007 15.0777 12 15.0777Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="nav-payment-requests" viewBox="0 0 24 24" fill="none"><path d="M12 19.3845C16.0784 19.3845 19.3846 16.0783 19.3846 11.9999C19.3846 7.92144 16.0784 4.61523 12 4.61523C7.92156 4.61523 4.61536 7.92144 4.61536 11.9999C4.61536 16.0783 7.92156 19.3845 12 19.3845Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10"/><path d="M9.53845 14.216L14.2769 9.41602" fill="none" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/><path d="M9.53845 10.707V14.2147H13.2308" fill="none" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="nav-payouts" viewBox="0 0 24 24" fill="none"><path d="M8.30766 4.61523H15.6923C17.723 4.61523 19.3846 6.27677 19.3846 8.30754V15.6922C19.3846 17.7229 17.723 19.3845 15.6923 19.3845H8.30766C6.27689 19.3845 4.61536 17.7229 4.61536 15.6922V8.30754C4.61536 6.27677 6.27689 4.61523 8.30766 4.61523Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10"/><path d="M8.30774 8.92383H15.6924" fill="none" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/><path d="M8.30774 12H15.6924" fill="none" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/><path d="M8.30774 15.0156H12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/></symbol>
-<symbol id="nav-plugin" viewBox="0 0 24 24" fill="none"><path d="M12.0002 10.2354L4.73633 7.38747M12.0002 10.2354L19.2642 7.38747M12.0002 10.2354V19.5M5.21166 7.01614L11.2783 4.6375C11.7412 4.45417 12.2566 4.45417 12.7196 4.6375L18.7862 7.01614C19.0023 7.1083 19.1858 7.26312 19.3131 7.46062C19.4404 7.65812 19.5055 7.88923 19.5002 8.12413V15.876C19.5058 16.1106 19.441 16.3415 19.3142 16.539C19.1874 16.7365 19.0045 16.8915 18.7888 16.984L12.7222 19.3633C12.259 19.5453 11.7441 19.5453 11.2809 19.3633L5.21433 16.984C4.9982 16.8919 4.81466 16.737 4.68739 16.5395C4.56012 16.342 4.49496 16.1109 4.50033 15.876V8.12413C4.49475 7.88953 4.55951 7.65864 4.68628 7.46117C4.81305 7.26371 4.99603 7.10871 5.21166 7.01614Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></symbol>
-<symbol id="nav-plugins-explore" viewBox="0 0 24 24" fill="none"><circle cx="11.75" cy="11.75" r="8.25" stroke="currentColor" stroke-width="1.5"/><path d="M10.3989 10.3096L13.7427 8.97203C14.1259 8.81873 14.5194 9.1696 14.4108 9.56783L13.3979 13.2815C13.3525 13.448 13.2245 13.5793 13.0592 13.6288L9.71436 14.6323C9.33296 14.7467 8.97735 14.3911 9.09177 14.0097L10.1057 10.6301C10.1494 10.4842 10.2574 10.3661 10.3989 10.3096Z" stroke="currentColor" stroke-width="1.5"/></symbol>
-<symbol id="nav-plugins-manage" viewBox="0 0 24 24" fill="none"><path d="M6 7H18" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M6 12H18" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M6 17H14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol>
+<symbol id="nav-plugin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 4.75v3"/><path d="M15 4.75v3"/><path d="M7.25 7.75h9.5v3.75a4.75 4.75 0 0 1-9.5 0V7.75Z"/><path d="M12 16.25v3"/><path d="M12 19.25h2.5a3.75 3.75 0 0 0 3.75-3.75v-.75"/></symbol>
<symbol id="nav-pointofsale" viewBox="0 0 24 24" fill="none"><path d="M16.88 19.4303H7.12002C6.79748 19.4322 6.47817 19.3659 6.18309 19.2356C5.88802 19.1054 5.62385 18.9141 5.40795 18.6745C5.19206 18.4349 5.02933 18.1522 4.93046 17.8452C4.83159 17.5382 4.79882 17.2137 4.83431 16.8931L5.60002 10.1617C5.6311 9.88087 5.76509 9.62152 5.97615 9.43368C6.1872 9.24584 6.46035 9.14284 6.74288 9.14455H17.2572C17.5397 9.14284 17.8129 9.24584 18.0239 9.43368C18.235 9.62152 18.369 9.88087 18.4 10.1617L19.1429 16.8931C19.1782 17.2118 19.146 17.5343 19.0485 17.8398C18.951 18.1452 18.7903 18.4267 18.5769 18.666C18.3634 18.9053 18.1021 19.097 17.8097 19.2286C17.5174 19.3603 17.2006 19.429 16.88 19.4303Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.42859 9.14369C7.42859 7.93128 7.91022 6.76852 8.76753 5.91121C9.62484 5.0539 10.7876 4.57227 12 4.57227C13.2124 4.57227 14.3752 5.0539 15.2325 5.91121C16.0898 6.76852 16.5714 7.93128 16.5714 9.14369" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M9.14282 12.5723H14.8571" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="nav-products" viewBox="0 0 24 24" fill="none"><g clip-path="url(#clip0_2163_13240)"><path d="M12.3218 3.48287L3.42387 12.3808C3.2286 12.5761 3.2286 12.8927 3.42387 13.0879L11.1796 20.8437C11.3749 21.039 11.6915 21.039 11.8868 20.8437L20.7847 11.9458C20.8785 11.852 20.9312 11.7248 20.9312 11.5922V3.83643C20.9312 3.56028 20.7073 3.33643 20.4312 3.33643L12.6754 3.33643C12.5428 3.33643 12.4156 3.3891 12.3218 3.48287Z" stroke="currentColor" stroke-width="1.5"/><path d="M6.16748 9.64062L14.6304 18.1035" stroke="currentColor" stroke-width="1.5"/><circle cx="17" cy="7" r="1" fill="currentColor"/></g></symbol>
<symbol id="nav-pull-payments" viewBox="0 0 24 24" fill="none"><path d="M12 20a8 8 0 1 1 0-16 8 8 0 0 1 0 16Zm0-15.19a7.2 7.2 0 0 0 0 14.38A7.2 7.2 0 0 0 12 4.8Z" fill="currentColor" stroke="currentColor" stroke-width=".6"/><path d="M9.48 14.85a.44.44 0 0 1-.3-.14c-.14-.16-.14-.43.05-.57l5.02-4.31c.16-.14.43-.14.57.05.14.17.14.44-.05.57l-5.05 4.29c-.05.08-.16.1-.24.1Z" fill="currentColor" stroke="currentColor" stroke-width=".6"/><path d="M14.39 14.28a.4.4 0 0 1-.41-.4l.1-3.42-3.08-.17a.4.4 0 0 1-.38-.43c0-.22.19-.4.43-.38l3.47.19c.22 0 .38.19.38.4l-.13 3.83c.02.19-.17.38-.38.38Z" fill="currentColor" stroke="currentColor" stroke-width=".6"/></symbol>
diff --git a/BTCPayServer/wwwroot/plugins/PluginManager/plugins-embed.js b/BTCPayServer/wwwroot/plugins/PluginManager/plugins-embed.js
new file mode 100644
index 0000000..fc8cdaa
--- /dev/null
+++ b/BTCPayServer/wwwroot/plugins/PluginManager/plugins-embed.js
@@ -0,0 +1,344 @@
+(function () {
+ const directoryShell = document.getElementById("plugins-embed-shell");
+ const directoryFrame = document.getElementById("plugins-directory-iframe");
+ const selectedPluginPanel = document.getElementById("selected-plugin-panel");
+ const selectedPluginOffcanvas = document.getElementById("selected-plugin-offcanvas");
+ const directoryErrorAlert = document.getElementById("plugins-embed-error-alert");
+ const directoryRetryButton = document.getElementById("plugins-embed-retry");
+ const hiddenPluginIdentifiersScript = document.getElementById("plugins-embed-hidden-plugin-identifiers");
+ const darkThemeLink = document.getElementById("DarkThemeLinkTag");
+ const installConfirmModal = document.getElementById("plugin-install-confirm-modal");
+ const installConfirmPluginInput = installConfirmModal.querySelector("input[name='plugin']");
+ const installConfirmVersionInput = installConfirmModal.querySelector("input[name='version']");
+ const installConfirmName = document.getElementById("plugin-install-confirm-name");
+ const installConfirmIdentifier = document.getElementById("plugin-install-confirm-identifier");
+ const installConfirmVersion = document.getElementById("plugin-install-confirm-version");
+ const installConfirmPending = document.getElementById("plugin-install-confirm-pending");
+ const installConfirmPendingVersion = document.getElementById("plugin-install-confirm-pending-version");
+ const installConfirmPendingRequestedVersion = document.getElementById("plugin-install-confirm-pending-requested-version");
+
+ const pluginDirectoryUrl = directoryShell.dataset.iframeUrl;
+ const selectedPluginPanelUrl = directoryShell.dataset.panelUrl;
+ const selectedPluginPanelErrorMessage = directoryShell.dataset.panelErrorMessage;
+ const selectedPluginPanelLoadingMessage = directoryShell.dataset.panelLoadingMessage;
+ const hiddenPluginIdentifiers = JSON.parse(hiddenPluginIdentifiersScript.textContent);
+ const directoryReadyTimeoutMs = 8000;
+ let selectedSlug = directoryShell.dataset.selectedSlug || "";
+ let directoryReady = false;
+ let directoryFailed = false;
+ let selectedPluginPanelRequestId = 0;
+ let directoryReadyTimeoutId = null;
+ let isInstallConfirmModalOpen = false;
+
+ if (!pluginDirectoryUrl || !selectedPluginPanelUrl) {
+ directoryRetryButton.classList.add("d-none");
+ showDirectoryError();
+ return;
+ }
+ const pluginBuilderOrigin = new URL(pluginDirectoryUrl).origin;
+
+ function startDirectory() {
+ directoryReady = false;
+ directoryFailed = false;
+ directoryErrorAlert.classList.add("d-none");
+ directoryShell.classList.remove("d-none");
+
+ window.clearTimeout(directoryReadyTimeoutId);
+ directoryReadyTimeoutId = window.setTimeout(function () {
+ if (!directoryReady) {
+ showDirectoryError();
+ }
+ }, directoryReadyTimeoutMs);
+
+ directoryFrame.src = pluginDirectoryUrl;
+ }
+
+ function showDirectoryError() {
+ if (directoryFailed) {
+ return;
+ }
+
+ directoryFailed = true;
+ window.clearTimeout(directoryReadyTimeoutId);
+ getOffcanvas().hide();
+ directoryShell.classList.add("d-none");
+ directoryErrorAlert.classList.remove("d-none");
+ }
+
+ function markReady() {
+ directoryReady = true;
+ window.clearTimeout(directoryReadyTimeoutId);
+ }
+
+ function getOffcanvas() {
+ return window.bootstrap.Offcanvas.getOrCreateInstance(selectedPluginOffcanvas);
+ }
+
+ function isObject(value) {
+ return value !== null && typeof value === "object" && !Array.isArray(value);
+ }
+
+ function isReadyMessage(data) {
+ return isObject(data) && data.type === "pb:ready";
+ }
+
+ Why this scored 38/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.