Refactor: Move Bitpay stuff in its own plugin
What changed, and why it matters
This commit is a large code refactor that moves BTCPay Server's Bitpay-compatible API from the core application into a separate plugin. It relocates controllers, authentication, models, views, and middleware into a new Plugins/Bitpay folder, updates tests and dependency injection, and replaces the old middleware-based Bitpay API detection with a new endpoint selector policy. The change itself is structural rather than a targeted security fix, but any large refactor of authentication and routing code carries a risk of accidentally changing access-control behavior.
Treat this as a high-touch refactor requiring careful regression testing of Bitpay API authentication, authorization, CORS behavior, and invoice/rates/tokens endpoints. Verify that the new endpoint selector policy enforces the same route visibility as the old BitpayAPIConstraintAttribute and that anonymous/public endpoints (e.g., /rates, anyone-can-create-invoice) still behave identically. Review plugin load order and ensure the Bitpay plugin is enabled by default in production deployments to avoid breaking existing integrations.
Security signals we found
Authentication and authorization handlers moved into a plugin
Routing/endpoint selection logic for Bitpay API rewritten
CORS handling for Bitpay endpoints relocated from middleware to controller/plugin layer
Core DI no longer registers Bitpay-specific services by default
Large file move with many namespace and using changes
Evidence from the diff
The commit refactors the Bitpay compatibility layer into a plugin. Key changes: deletion of core controllers (BitpayAccessTokenController, BitpayInvoiceController, BitpayRateController, UIStoresController.Tokens) and their recreation under BTCPayServer.Plugins.Bitpay; relocation of Bitpay authentication, security claims, models, and views into the plugin; removal of the global BitpayAPIConstraintAttribute and BTCPayMiddleware’s Bitpay detection/CORS handling; introduction of BitpayEndpointSelectorPolicy to decide whether a request should match Bitpay endpoints; removal of TokenRepository and BitpayAuthorizationHandler from core DI; replacement of UseMiddleware
Changed components
BTCPayServer.Plugins.BitpayBitpay authentication handler and authorization handlerBitpay invoice/rates/tokens controllersUI token management (UIStoresTokenController)Middleware pipeline (BTCPayMiddleware, GreenfieldMiddleware, OnionLocationMiddleware, SetCultureMiddleware)Endpoint routing (BitpayEndpointSelectorPolicy)CORS configuration in Startup.csIntegration tests (BitpayTests.cs)Inspect captured patch +2561 / −2544
diff --git a/BTCPayServer.Tests/BitpayTests.cs b/BTCPayServer.Tests/BitpayTests.cs
new file mode 100644
index 0000000..5a156c1
--- /dev/null
+++ b/BTCPayServer.Tests/BitpayTests.cs
@@ -0,0 +1,404 @@
+using System;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+using BTCPayServer.Abstractions.Constants;
+using BTCPayServer.Client.Models;
+using BTCPayServer.Events;
+using BTCPayServer.Plugins.Bitpay.Controllers;
+using BTCPayServer.Plugins.Bitpay.Models;
+using BTCPayServer.Plugins.Bitpay.Security;
+using BTCPayServer.Plugins.Bitpay.Views;
+using BTCPayServer.Views.Stores;
+using Microsoft.AspNetCore.Mvc;
+using NBitcoin;
+using NBitcoin.DataEncoders;
+using NBitcoin.Payment;
+using NBitpayClient;
+using Newtonsoft.Json;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace BTCPayServer.Tests;
+
+[Collection(nameof(NonParallelizableCollectionDefinition))]
+public class BitpayTests(ITestOutputHelper log) : UnitTestBase(log)
+{
+ [Fact]
+ [Trait("Integration", "Integration")]
+ public async Task CanThrowBitpay404Error()
+ {
+ using var tester = CreateServerTester();
+ await tester.StartAsync();
+ var user = tester.NewAccount();
+ await user.GrantAccessAsync();
+ user.RegisterDerivationScheme("BTC");
+
+ var invoice = await user.BitPay.CreateInvoiceAsync(
+ new Invoice()
+ {
+ Buyer = new Buyer() { email = "test@fwf.com" },
+ Price = 5000.0m,
+ Currency = "USD",
+ PosData = "posData",
+ OrderId = "orderId",
+ ItemDesc = "Some description",
+ FullNotifications = true
+ }, Facade.Merchant);
+
+ try
+ {
+ await user.BitPay.GetInvoiceAsync(invoice.Id + "123");
+ }
+ catch (BitPayException ex)
+ {
+ Assert.Equal("Object not found", ex.Errors.First());
+ }
+
+ var req = new HttpRequestMessage(HttpMethod.Get, "/invoices/Cy9jfK82eeEED1T3qhwF3Y");
+ req.Headers.TryAddWithoutValidation("Authorization", "Basic dGVzdA==");
+ req.Content = new StringContent("{}", Encoding.UTF8, "application/json");
+ var result = await tester.PayTester.HttpClient.SendAsync(req);
+ Assert.Equal(HttpStatusCode.Unauthorized, result.StatusCode);
+ var err = await result.Content.ReadAsStringAsync();
+ var errModel = JsonConvert.DeserializeObject<BitpayErrorsModel>(err);
+ Assert.Equal("ApiKey authentication failed", errModel.Errors[0].Error);
+ }
+
+ [Fact]
+ [Trait("Integration", "Integration")]
+ public async Task CanUseServerInitiatedPairingCode()
+ {
+ using var tester = CreateServerTester();
+ await tester.StartAsync();
+ var acc = tester.NewAccount();
+ await acc.RegisterAsync();
+ acc.CreateStore();
+
+ var controller = acc.GetController<UIStoresTokenController>();
+ var token = (RedirectToActionResult)await controller.CreateToken2(
+ new CreateTokenViewModel()
+ {
+ Label = "bla",
+ PublicKey = null,
+ StoreId = acc.StoreId
+ });
+
+ var pairingCode = (string)token.RouteValues!["pairingCode"];
+
+ await acc.BitPay.AuthorizeClient(new PairingCode(pairingCode));
+ Assert.True(await acc.BitPay.TestAccessAsync(Facade.Merchant));
+ }
+
+ [Fact]
+ [Trait("Integration", "Integration")]
+ public async Task CanSendIPN()
+ {
+ using var callbackServer = new CustomServer();
+ using var tester = CreateServerTester();
+ await tester.StartAsync();
+ var acc = tester.NewAccount();
+ await acc.GrantAccessAsync();
+ acc.RegisterDerivationScheme("BTC");
+ await acc.ModifyGeneralSettings(p => p.SpeedPolicy = SpeedPolicy.LowSpeed);
+ var invoice = await acc.BitPay.CreateInvoiceAsync(new Invoice
+ {
+ Price = 5.0m,
+ Currency = "USD",
+ PosData = "posData",
+ OrderId = "orderId",
+ NotificationURL = callbackServer.GetUri().AbsoluteUri,
+ ItemDesc = "Some description",
+ FullNotifications = true,
+ ExtendedNotifications = true
+ });
+#pragma warning disable CS0618
+ var url = new BitcoinUrlBuilder(invoice.PaymentUrls.BIP21,
+ tester.NetworkProvider.BTC.NBitcoinNetwork);
+ var receivedPayment = false;
+ var paid = false;
+ var confirmed = false;
+ var completed = false;
+ while (!completed || !confirmed || !receivedPayment)
+ {
+ var request = await callbackServer.GetNextRequest();
+ if (request.ContainsKey("event"))
+ {
+ var evtName = request["event"]!.Value<string>("name");
+ switch (evtName)
+ {
+ case InvoiceEvent.Created:
+ await tester.ExplorerNode.SendToAddressAsync(url.Address!, url.Amount!);
+ break;
+ case InvoiceEvent.ReceivedPayment:
+ receivedPayment = true;
+ break;
+ case InvoiceEvent.PaidInFull:
+ // TODO, we should check that ReceivedPayment is sent after PaidInFull
+ // for now, we can't ensure this because the ReceivedPayment events isn't sent by the
+ // InvoiceWatcher, contrary to all other events
+ await tester.ExplorerNode.GenerateAsync(6);
+ paid = true;
+ break;
+ case InvoiceEvent.Confirmed:
+ Assert.True(paid);
+ confirmed = true;
+ break;
+ case InvoiceEvent.Completed:
+ Assert.True(
+ paid); //TODO: Fix, out of order event mean we can receive invoice_confirmed after invoice_complete
+ completed = true;
+ break;
+ default:
+ Assert.Fail($"{evtName} was not expected");
+ break;
+ }
+ }
+ }
+ var invoice2 = await acc.BitPay.GetInvoiceAsync(invoice.Id);
+ Assert.NotNull(invoice2);
+ }
+
+ [Fact]
+ [Trait("Integration", "Integration")]
+ public async Task CantPairTwiceWithSamePubkey()
+ {
+ using var tester = CreateServerTester();
+ await tester.StartAsync();
+ var acc = tester.NewAccount();
+ await acc.RegisterAsync();
+ acc.CreateStore();
+ var store = acc.GetController<UIStoresTokenController>();
+ var pairingCode = await acc.BitPay.RequestClientAuthorizationAsync("test", Facade.Merchant);
+ Assert.IsType<RedirectToActionResult>(store.Pair(pairingCode.ToString(), acc.StoreId).GetAwaiter()
+ .GetResult());
+
+ pairingCode = await acc.BitPay.RequestClientAuthorizationAsync("test1", Facade.Merchant);
+ acc.CreateStore();
+ var store2 = acc.GetController<UIStoresTokenController>();
+ await store2.Pair(pairingCode.ToString(), store2.CurrentStore.Id);
+ Assert.Contains(nameof(PairingResult.ReusedKey),
+ store2.TempData[WellKnownTempData.ErrorMessage].ToString(), StringComparison.CurrentCultureIgnoreCase);
+ }
+
+
+ [Fact]
+ [Trait("Integration", "Integration")]
+ public async Task CheckCORSSetOnBitpayAPI()
+ {
+ using var tester = CreateServerTester();
+ await tester.StartAsync();
+ foreach (var req in new[] { "invoices/", "invoices", "rates", "tokens" }.Select(async path =>
+ {
+ using var client = new HttpClient();
+ var message = new HttpRequestMessage(HttpMethod.Options,
+ tester.PayTester.ServerUri.AbsoluteUri + path);
+ message.Headers.Add("Access-Control-Request-Headers", "test");
+ message.Headers.TryAddWithoutValidation("Origin", "https://test.com");
+ message.Headers.TryAddWithoutValidation("Access-Control-Request-Method", "GET");
+ var response = await client.SendAsync(message);
+ response.EnsureSuccessStatusCode();
+ Assert.True(response.Headers.TryGetValues("Access-Control-Allow-Origin", out var val));
+ Assert.Equal("*", val.FirstOrDefault());
+ Assert.True(response.Headers.TryGetValues("Access-Control-Allow-Headers", out val));
+ Assert.Equal("test", val.FirstOrDefault());
+ }).ToList())
+ {
+ await req;
+ }
+
+
+ var client2 = new HttpClient();
+ var message2 = new HttpRequestMessage(HttpMethod.Options,
+ tester.PayTester.ServerUri.AbsoluteUri + "rates");
+ message2.Headers.TryAddWithoutValidation("Origin", "https://test.com");
+ message2.Headers.TryAddWithoutValidation("Access-Control-Request-Method", "GET");
+ var response2 = await client2.SendAsync(message2);
+ response2.EnsureSuccessStatusCode();
+ Assert.True(response2.Headers.TryGetValues("Access-Control-Allow-Origin", out var val2));
+ Assert.Equal("*", val2.FirstOrDefault());
+ }
+
+ [Fact]
+ [Trait("Integration", "Integration")]
+ public async Task TestAccessBitpayAPI()
+ {
+ using var tester = CreateServerTester();
+ await tester.StartAsync();
+ var user = tester.NewAccount();
+ Assert.False(await user.BitPay.TestAccessAsync(Facade.Merchant));
+ await user.GrantAccessAsync();
+ user.RegisterDerivationScheme("BTC");
+
+ Assert.True(await user.BitPay.TestAccessAsync(Facade.Merchant));
+
+ // Test request pairing code client side
+ var storeController = user.GetController<UIStoresTokenController>();
+ await storeController
+ .CreateToken(user.StoreId, new CreateTokenViewModel() { Label = "test2", StoreId = user.StoreId });
+ Assert.NotNull(storeController.GeneratedPairingCode);
+
+
+ var k = new Key();
+ var bitpay = new Bitpay(k, tester.PayTester.ServerUri);
+ bitpay.AuthorizeClient(new PairingCode(storeController.GeneratedPairingCode)).Wait();
+ Assert.True(await bitpay.TestAccessAsync(Facade.Merchant));
+ Assert.True(await bitpay.TestAccessAsync(Facade.PointOfSale));
+ // Same with a new instance
+ bitpay = new Bitpay(k, tester.PayTester.ServerUri);
+ Assert.True(await bitpay.TestAccessAsync(Facade.Merchant));
+ Assert.True(await bitpay.TestAccessAsync(Facade.PointOfSale));
+ var client = new HttpClient();
+ var token = (await bitpay.GetAccessTokenAsync(Facade.Merchant)).Value;
+ var getRates = tester.PayTester.ServerUri.AbsoluteUri + $"rates/?cryptoCode=BTC&token={token}";
+ var req = new HttpRequestMessage(HttpMethod.Get, getRates);
+ req.Headers.Add("x-signature", NBitpayClient.Extensions.BitIdExtensions.GetBitIDSignature(k, getRates, null));
+ req.Headers.Add("x-identity", k.PubKey.ToHex());
+ var resp = await client.SendAsync(req);
+ resp.EnsureSuccessStatusCode();
+
+ // Can generate API Key
+ var repo = tester.PayTester.GetService<TokenRepository>();
+ Assert.Empty(await repo.GetLegacyAPIKeys(user.StoreId));
+ Assert.IsType<RedirectToActionResult>(await user.GetController<UIStoresTokenController>()
+ .GenerateAPIKey(user.StoreId));
+
+ var apiKey = Assert.Single(await repo.GetLegacyAPIKeys(user.StoreId));
+ ///////
+
+ // Generating a new one remove the previous
+ Assert.IsType<RedirectToActionResult>(await user.GetController<UIStoresTokenController>()
+ .GenerateAPIKey(user.StoreId));
+ var apiKey2 = Assert.Single(await repo.GetLegacyAPIKeys(user.StoreId));
+ Assert.NotEqual(apiKey, apiKey2);
+ ////////
+
+ apiKey = apiKey2;
+
+ // Can create an invoice with this new API Key
+ var message = new HttpRequestMessage(HttpMethod.Post,
+ tester.PayTester.ServerUri.AbsoluteUri + "invoices");
+ message.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic",
+ Encoders.Base64.EncodeData(Encoders.ASCII.DecodeData(apiKey)));
+ var invoice = new Invoice() { Price = 5000.0m, Currency = "USD" };
+ message.Content = new StringContent(JsonConvert.SerializeObject(invoice), Encoding.UTF8,
+ "application/json");
+ var result = await client.SendAsync(message);
+ result.EnsureSuccessStatusCode();
+ /////////////////////
+
+ // Have error 403 with a bad signature
+ client = new HttpClient();
+ var mess =
+ new HttpRequestMessage(HttpMethod.Get, tester.PayTester.ServerUri.AbsoluteUri + "tokens");
+ mess.Content = new StringContent(string.Empty, Encoding.UTF8, "application/json");
+ mess.Headers.Add("x-signature",
+ "3045022100caa123193afc22ef93d9c6b358debce6897c09dd9869fe6fe029c9cb43623fac022000b90c65c50ba8bbbc6ebee8878abe5659e17b9f2e1b27d95eda4423da5608fe");
+ mess.Headers.Add("x-identity",
+ "04b4d82095947262dd70f94c0a0e005ec3916e3f5f2181c176b8b22a52db22a8c436c4703f43a9e8884104854a11e1eb30df8fdf116e283807a1f1b8fe4c182b99");
+ mess.Method = HttpMethod.Get;
+ result = await client.SendAsync(mess);
+ Assert.Equal(HttpStatusCode.Unauthorized, result.StatusCode);
+
+ //
+ }
+
+ [Fact]
+ [Trait("Integration", "Integration")]
+ public async Task CanUseAnyoneCanCreateInvoice()
+ {
+ using var tester = CreateServerTester();
+ await tester.StartAsync();
+ var user = tester.NewAccount();
+ await user.GrantAccessAsync();
+ user.RegisterDerivationScheme("BTC");
+
+ TestLogs.LogInformation("StoreId without anyone can create invoice = 403");
+ var response = await tester.PayTester.HttpClient.SendAsync(
+ new HttpRequestMessage(HttpMethod.Post, $"invoices?storeId={user.StoreId}")
+ {
+ Content = new StringContent("{\"Price\": 5000, \"currency\": \"USD\"}", Encoding.UTF8,
+ "application/json"),
+ });
+ Assert.Equal(403, (int)response.StatusCode);
+
+ TestLogs.LogInformation(
+ "No store without anyone can create invoice = 404 because the bitpay API can't know the storeid");
+ response = await tester.PayTester.HttpClient.SendAsync(
+ new HttpRequestMessage(HttpMethod.Post, $"invoices")
+ {
+ Content = new StringContent("{\"Price\": 5000, \"currency\": \"USD\"}", Encoding.UTF8,
+ "application/json"),
+ });
+ Assert.Equal(404, (int)response.StatusCode);
+
+ await user.ModifyPayment(p => p.AnyoneCanCreateInvoice = true);
+
+ TestLogs.LogInformation("Bad store with anyone can create invoice = 403");
+ response = await tester.PayTester.HttpClient.SendAsync(
+ new HttpRequestMessage(HttpMethod.Post, $"invoices?storeId=badid")
+ {
+ Content = new StringContent("{\"Price\": 5000, \"currency\": \"USD\"}", Encoding.UTF8,
+ "application/json"),
+ });
+ Assert.Equal(403, (int)response.StatusCode);
+
+ TestLogs.LogInformation("Good store with anyone can create invoice = 200");
+ response = await tester.PayTester.HttpClient.SendAsync(
+ new HttpRequestMessage(HttpMethod.Post, $"invoices?storeId={user.StoreId}")
+ {
+ Content = new StringContent("{\"Price\": 5000, \"currency\": \"USD\"}", Encoding.UTF8,
+ "application/json"),
+ });
+ Assert.Equal(200, (int)response.StatusCode);
+ }
+
+ [Fact]
+ public async Task CanUsePairing()
+ {
+ await using var s = CreatePlaywrightTester();
+ await s.StartAsync();
+ await s.Page.GotoAsync(s.Link("/api-access-request"));
+ Assert.Contains("ReturnUrl", s.Page.Url);
+ await s.GoToRegister();
+ await s.RegisterNewUser();
+ await s.CreateNewStore();
+ await s.AddDerivationScheme();
+
+ await s.GoToStore(s.StoreId, StoreNavPages.Tokens);
+ await s.Page.Locator("#CreateNewToken").ClickAsync();
+ await s.ClickPagePrimary();
+ var url = s.Page.Url;
+ var pairingCode = Regex.Match(new Uri(url, UriKind.Absolute).Query, "pairingCode=([^&]*)").Groups[1].Value;
+
+ await s.ClickPagePrimary();
+ await s.FindAlertMessage();
+ Assert.Contains(pairingCode, await s.Page.ContentAsync());
+
+ var client = new Bitpay(new Key(), s.ServerUri);
+ await client.AuthorizeClient(new PairingCode(pairingCode));
+ await client.CreateInvoiceAsync(
+ new Invoice { Price = 1.000000012m, Currency = "USD", FullNotifications = true },
+ Facade.Merchant);
+
+ client = new Bitpay(new Key(), s.ServerUri);
+
+ var code = await client.RequestClientAuthorizationAsync("hehe", Facade.Merchant);
+ await s.Page.GotoAsync(code.CreateLink(s.ServerUri).ToString());
+ await s.ClickPagePrimary();
+
+ await client.CreateInvoiceAsync(
+ new Invoice { Price = 1.000000012m, Currency = "USD", FullNotifications = true },
+ Facade.Merchant);
+
+ await s.Page.GotoAsync(s.Link("/api-tokens"));
+ await s.ClickPagePrimary(); // Request
+ await s.ClickPagePrimary(); // Approve
+ var url2 = s.Page.Url;
+ var pairingCode2 = Regex.Match(new Uri(url2, UriKind.Absolute).Query, "pairingCode=([^&]*)").Groups[1].Value;
+ Assert.False(string.IsNullOrEmpty(pairingCode2));
+ }
+}
diff --git a/BTCPayServer.Tests/GreenfieldAPITests.cs b/BTCPayServer.Tests/GreenfieldAPITests.cs
index ec81284..8a8c69e 100644
--- a/BTCPayServer.Tests/GreenfieldAPITests.cs
+++ b/BTCPayServer.Tests/GreenfieldAPITests.cs
@@ -24,6 +24,7 @@ using BTCPayServer.Services.Notifications;
using BTCPayServer.Services.Notifications.Blobs;
using BTCPayServer.Services.Stores;
using Dapper;
+using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
diff --git a/BTCPayServer.Tests/PlaywrightTests.cs b/BTCPayServer.Tests/PlaywrightTests.cs
index 7bd0757..b8e50ff 100644
--- a/BTCPayServer.Tests/PlaywrightTests.cs
+++ b/BTCPayServer.Tests/PlaywrightTests.cs
@@ -4,7 +4,6 @@ using System.Globalization;
using System.Linq;
using System.Net;
using System.Net.Http;
-using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Web;
using BTCPayServer.Abstractions.Models;
@@ -31,7 +30,6 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Playwright;
using NBitcoin;
using NBitcoin.Altcoins;
-using NBitpayClient;
using NBXplorer;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
@@ -1981,52 +1979,6 @@ namespace BTCPayServer.Tests
Assert.Contains("PP1", pageContent);
}
- [Fact]
- public async Task CanUsePairing()
- {
- await using var s = CreatePlaywrightTester();
- await s.StartAsync();
- await s.Page.GotoAsync(s.Link("/api-access-request"));
- Assert.Contains("ReturnUrl", s.Page.Url);
- await s.GoToRegister();
- await s.RegisterNewUser();
- await s.CreateNewStore();
- await s.AddDerivationScheme();
-
- await s.GoToStore(s.StoreId, StoreNavPages.Tokens);
- await s.Page.Locator("#CreateNewToken").ClickAsync();
- await s.ClickPagePrimary();
- var url = s.Page.Url;
- var pairingCode = Regex.Match(new Uri(url, UriKind.Absolute).Query, "pairingCode=([^&]*)").Groups[1].Value;
-
- await s.ClickPagePrimary();
- await s.FindAlertMessage();
- Assert.Contains(pairingCode, await s.Page.ContentAsync());
-
- var client = new Bitpay(new Key(), s.ServerUri);
- await client.AuthorizeClient(new PairingCode(pairingCode));
- await client.CreateInvoiceAsync(
- new Invoice { Price = 1.000000012m, Currency = "USD", FullNotifications = true },
- Facade.Merchant);
-
- client = new Bitpay(new Key(), s.ServerUri);
-
- var code = await client.RequestClientAuthorizationAsync("hehe", Facade.Merchant);
- await s.Page.GotoAsync(code.CreateLink(s.ServerUri).ToString());
- await s.ClickPagePrimary();
-
- await client.CreateInvoiceAsync(
- new Invoice { Price = 1.000000012m, Currency = "USD", FullNotifications = true },
- Facade.Merchant);
-
- await s.Page.GotoAsync(s.Link("/api-tokens"));
- await s.ClickPagePrimary(); // Request
- await s.ClickPagePrimary(); // Approve
- var url2 = s.Page.Url;
- var pairingCode2 = Regex.Match(new Uri(url2, UriKind.Absolute).Query, "pairingCode=([^&]*)").Groups[1].Value;
- Assert.False(string.IsNullOrEmpty(pairingCode2));
- }
-
[Fact]
[Trait("Lightning", "Lightning")]
public async Task CanCreateStores()
diff --git a/BTCPayServer.Tests/TestAccount.cs b/BTCPayServer.Tests/TestAccount.cs
index 249f713..fdcd0b5 100644
--- a/BTCPayServer.Tests/TestAccount.cs
+++ b/BTCPayServer.Tests/TestAccount.cs
@@ -116,7 +116,7 @@ namespace BTCPayServer.Tests
{
await RegisterAsync(isAdmin);
await CreateStoreAsync();
- var store = GetController<UIStoresController>();
+ var store = GetController<BTCPayServer.Plugins.Bitpay.Controllers.UIStoresTokenController>();
var pairingCode = BitPay.RequestClientAuthorization("test", Facade.Merchant);
Assert.IsType<ViewResult>(await store.RequestPairing(pairingCode.ToString()));
await store.Pair(pairingCode.ToString(), StoreId);
diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs
index c5be1d4..ad0abb1 100644
--- a/BTCPayServer.Tests/UnitTest1.cs
+++ b/BTCPayServer.Tests/UnitTest1.cs
@@ -10,7 +10,6 @@ using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
-using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
@@ -36,9 +35,9 @@ using BTCPayServer.Models.WalletViewModels;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Payments.PayJoin.Sender;
+using BTCPayServer.Plugins.Bitpay.Controllers;
using BTCPayServer.Plugins.PointOfSale;
using BTCPayServer.Plugins.PointOfSale.Controllers;
-using BTCPayServer.Security.Bitpay;
using BTCPayServer.Security.Greenfield;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
@@ -56,8 +55,6 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using NBitcoin;
-using NBitcoin.DataEncoders;
-using NBitcoin.Payment;
using NBitpayClient;
using NBXplorer;
using NBXplorer.Models;
@@ -328,160 +325,6 @@ namespace BTCPayServer.Tests
});
}
- [Fact]
- [Trait("Integration", "Integration")]
- public async Task CanThrowBitpay404Error()
- {
- using var tester = CreateServerTester();
- await tester.StartAsync();
- var user = tester.NewAccount();
- user.GrantAccess();
- user.RegisterDerivationScheme("BTC");
-
- var invoice = user.BitPay.CreateInvoice(
- new Invoice()
- {
- Buyer = new Buyer() { email = "test@fwf.com" },
- Price = 5000.0m,
- Currency = "USD",
- PosData = "posData",
- OrderId = "orderId",
- ItemDesc = "Some description",
- FullNotifications = true
- }, Facade.Merchant);
-
- try
- {
- user.BitPay.GetInvoice(invoice.Id + "123");
- }
- catch (BitPayException ex)
- {
- Assert.Equal("Object not found", ex.Errors.First());
- }
- var req = new HttpRequestMessage(HttpMethod.Get, "/invoices/Cy9jfK82eeEED1T3qhwF3Y");
- req.Headers.TryAddWithoutValidation("Authorization", "Basic dGVzdA==");
- req.Content = new StringContent("{}", Encoding.UTF8, "application/json");
- var result = await tester.PayTester.HttpClient.SendAsync(req);
- Assert.Equal(HttpStatusCode.Unauthorized, result.StatusCode);
- Assert.Equal(0, result.Content.Headers.ContentLength.Value);
- }
-
- [Fact(Timeout = LongRunningTestTimeout)]
- [Trait("Integration", "Integration")]
- public async Task CanUseServerInitiatedPairingCode()
- {
- using var tester = CreateServerTester();
- await tester.StartAsync();
- var acc = tester.NewAccount();
- acc.Register();
- acc.CreateStore();
-
- var controller = acc.GetController<UIStoresController>();
- var token = (RedirectToActionResult)await controller.CreateToken2(
- new Models.StoreViewModels.CreateTokenViewModel()
- {
- Label = "bla",
- PublicKey = null,
- StoreId = acc.StoreId
- });
-
- var pairingCode = (string)token.RouteValues["pairingCode"];
-
- await acc.BitPay.AuthorizeClient(new PairingCode(pairingCode));
- Assert.True(acc.BitPay.TestAccess(Facade.Merchant));
- }
-
- [Fact(Timeout = LongRunningTestTimeout)]
- [Trait("Integration", "Integration")]
- public async Task CanSendIPN()
- {
- using var callbackServer = new CustomServer();
- using var tester = CreateServerTester();
- await tester.StartAsync();
- var acc = tester.NewAccount();
- await acc.GrantAccessAsync();
- acc.RegisterDerivationScheme("BTC");
- await acc.ModifyGeneralSettings(p => p.SpeedPolicy = SpeedPolicy.LowSpeed);
- var invoice = await acc.BitPay.CreateInvoiceAsync(new Invoice
- {
- Price = 5.0m,
- Currency = "USD",
- PosData = "posData",
- OrderId = "orderId",
- NotificationURL = callbackServer.GetUri().AbsoluteUri,
- ItemDesc = "Some description",
- FullNotifications = true,
- ExtendedNotifications = true
- });
-#pragma warning disable CS0618
- BitcoinUrlBuilder url = new BitcoinUrlBuilder(invoice.PaymentUrls.BIP21,
- tester.NetworkProvider.BTC.NBitcoinNetwork);
- bool receivedPayment = false;
- bool paid = false;
- bool confirmed = false;
- bool completed = false;
- while (!completed || !confirmed || !receivedPayment)
- {
- var request = await callbackServer.GetNextRequest();
- if (request.ContainsKey("event"))
- {
- var evtName = request["event"]["name"].Value<string>();
- switch (evtName)
- {
- case InvoiceEvent.Created:
- tester.ExplorerNode.SendToAddress(url.Address, url.Amount);
- break;
- case InvoiceEvent.ReceivedPayment:
- receivedPayment = true;
- break;
- case InvoiceEvent.PaidInFull:
- // TODO, we should check that ReceivedPayment is sent after PaidInFull
- // for now, we can't ensure this because the ReceivedPayment events isn't sent by the
- // InvoiceWatcher, contrary to all other events
- tester.ExplorerNode.Generate(6);
- paid = true;
- break;
- case InvoiceEvent.Confirmed:
- Assert.True(paid);
- confirmed = true;
- break;
- case InvoiceEvent.Completed:
- Assert.True(
- paid); //TODO: Fix, out of order event mean we can receive invoice_confirmed after invoice_complete
- completed = true;
- break;
- default:
- Assert.Fail($"{evtName} was not expected");
- break;
- }
- }
- }
- var invoice2 = acc.BitPay.GetInvoice(invoice.Id);
- Assert.NotNull(invoice2);
- }
-
- [Fact(Timeout = LongRunningTestTimeout)]
- [Trait("Integration", "Integration")]
- public async Task CantPairTwiceWithSamePubkey()
- {
- using var tester = CreateServerTester();
- await tester.StartAsync();
- var acc = tester.NewAccount();
- acc.Register();
- acc.CreateStore();
- var store = acc.GetController<UIStoresController>();
- var pairingCode = acc.BitPay.RequestClientAuthorization("test", Facade.Merchant);
- Assert.IsType<RedirectToActionResult>(store.Pair(pairingCode.ToString(), acc.StoreId).GetAwaiter()
- .GetResult());
-
- pairingCode = acc.BitPay.RequestClientAuthorization("test1", Facade.Merchant);
- acc.CreateStore();
- var store2 = acc.GetController<UIStoresController>();
- await store2.Pair(pairingCode.ToString(), store2.CurrentStore.Id);
- Assert.Contains(nameof(PairingResult.ReusedKey),
- store2.TempData[WellKnownTempData.ErrorMessage].ToString(), StringComparison.CurrentCultureIgnoreCase);
- }
-
[Fact(Timeout = LongRunningTestTimeout * 2)]
[Trait("Flaky", "Flaky")]
public async Task CanUseTorClient()
@@ -933,171 +776,6 @@ namespace BTCPayServer.Tests
Assert.NotNull(paymentData["keyPath"]);
}
- [Fact(Timeout = LongRunningTestTimeout)]
- [Trait("Integration", "Integration")]
- public async Task CheckCORSSetOnBitpayAPI()
- {
- using var tester = CreateServerTester();
- await tester.StartAsync();
- foreach (var req in new[] { "invoices/", "invoices", "rates", "tokens" }.Select(async path =>
- {
- using HttpClient client = new HttpClient();
- HttpRequestMessage message = new HttpRequestMessage(HttpMethod.Options,
- tester.PayTester.ServerUri.AbsoluteUri + path);
- message.Headers.Add("Access-Control-Request-Headers", "test");
- var response = await client.SendAsync(message);
- response.EnsureSuccessStatusCode();
- Assert.True(response.Headers.TryGetValues("Access-Control-Allow-Origin", out var val));
- Assert.Equal("*", val.FirstOrDefault());
- Assert.True(response.Headers.TryGetValues("Access-Control-Allow-Headers", out val));
- Assert.Equal("test", val.FirstOrDefault());
- }).ToList())
- {
- await req;
- }
-
- HttpClient client2 = new HttpClient();
- HttpRequestMessage message2 = new HttpRequestMessage(HttpMethod.Options,
- tester.PayTester.ServerUri.AbsoluteUri + "rates");
- var response2 = await client2.SendAsync(message2);
- Assert.True(response2.Headers.TryGetValues("Access-Control-Allow-Origin", out var val2));
- Assert.Equal("*", val2.FirstOrDefault());
- }
-
- [Fact(Timeout = LongRunningTestTimeout)]
- [Trait("Integration", "Integration")]
- public async Task TestAccessBitpayAPI()
- {
- using var tester = CreateServerTester();
- await tester.StartAsync();
- var user = tester.NewAccount();
- Assert.False(user.BitPay.TestAccess(Facade.Merchant));
- user.GrantAccess();
- user.RegisterDerivationScheme("BTC");
-
- Assert.True(user.BitPay.TestAccess(Facade.Merchant));
-
- // Test request pairing code client side
- var storeController = user.GetController<UIStoresController>();
- await storeController
- .CreateToken(user.StoreId, new CreateTokenViewModel() { Label = "test2", StoreId = user.StoreId });
- Assert.NotNull(storeController.GeneratedPairingCode);
-
-
- var k = new Key();
- var bitpay = new Bitpay(k, tester.PayTester.ServerUri);
- bitpay.AuthorizeClient(new PairingCode(storeController.GeneratedPairingCode)).Wait();
- Assert.True(bitpay.TestAccess(Facade.Merchant));
- Assert.True(bitpay.TestAccess(Facade.PointOfSale));
- // Same with new instance
- bitpay = new Bitpay(k, tester.PayTester.ServerUri);
- Assert.True(bitpay.TestAccess(Facade.Merchant));
- Assert.True(bitpay.TestAccess(Facade.PointOfSale));
- HttpClient client = new HttpClient();
- var token = (await bitpay.GetAccessTokenAsync(Facade.Merchant)).Value;
- var getRates = tester.PayTester.ServerUri.AbsoluteUri + $"rates/?cryptoCode=BTC&token={token}";
- var req = new HttpRequestMessage(HttpMethod.Get, getRates);
- req.Headers.Add("x-signature", NBitpayClient.Extensions.BitIdExtensions.GetBitIDSignature(k, getRates, null));
- req.Headers.Add("x-identity", k.PubKey.ToHex());
- var resp = await client.SendAsync(req);
- resp.EnsureSuccessStatusCode();
-
- // Can generate API Key
- var repo = tester.PayTester.GetService<TokenRepository>();
- Assert.Empty(await repo.GetLegacyAPIKeys(user.StoreId));
- Assert.IsType<RedirectToActionResult>(await user.GetController<UIStoresController>()
- .GenerateAPIKey(user.StoreId));
-
- var apiKey = Assert.Single(await repo.GetLegacyAPIKeys(user.StoreId));
- ///////
-
- // Generating a new one remove the previous
- Assert.IsType<RedirectToActionResult>(await user.GetController<UIStoresController>()
- .GenerateAPIKey(user.StoreId));
- var apiKey2 = Assert.Single(await repo.GetLegacyAPIKeys(user.StoreId));
- Assert.NotEqual(apiKey, apiKey2);
- ////////
-
- apiKey = apiKey2;
-
- // Can create an invoice with this new API Key
- HttpRequestMessage message = new HttpRequestMessage(HttpMethod.Post,
- tester.PayTester.ServerUri.AbsoluteUri + "invoices");
- message.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic",
- Encoders.Base64.EncodeData(Encoders.ASCII.DecodeData(apiKey)));
- var invoice = new Invoice() { Price = 5000.0m, Currency = "USD" };
- message.Content = new StringContent(JsonConvert.SerializeObject(invoice), Encoding.UTF8,
- "application/json");
- var result = await client.SendAsync(message);
- result.EnsureSuccessStatusCode();
- /////////////////////
-
- // Have error 403 with bad signature
- client = new HttpClient();
- HttpRequestMessage mess =
- new HttpRequestMessage(HttpMethod.Get, tester.PayTester.ServerUri.AbsoluteUri + "tokens");
- mess.Content = new StringContent(string.Empty, Encoding.UTF8, "application/json");
- mess.Headers.Add("x-signature",
- "3045022100caa123193afc22ef93d9c6b358debce6897c09dd9869fe6fe029c9cb43623fac022000b90c65c50ba8bbbc6ebee8878abe5659e17b9f2e1b27d95eda4423da5608fe");
- mess.Headers.Add("x-identity",
- "04b4d82095947262dd70f94c0a0e005ec3916e3f5f2181c176b8b22a52db22a8c436c4703f43a9e8884104854a11e1eb30df8fdf116e283807a1f1b8fe4c182b99");
- mess.Method = HttpMethod.Get;
- result = await client.SendAsync(mess);
- Assert.Equal(System.Net.HttpStatusCode.Unauthorized, result.StatusCode);
-
- //
- }
-
- [Fact(Timeout = LongRunningTestTimeout)]
- [Trait("Integration", "Integration")]
- public async Task CanUseAnyoneCanCreateInvoice()
- {
- using var tester = CreateServerTester();
- await tester.StartAsync();
- var user = tester.NewAccount();
- user.GrantAccess();
- user.RegisterDerivationScheme("BTC");
-
- TestLogs.LogInformation("StoreId without anyone can create invoice = 403");
- var response = await tester.PayTester.HttpClient.SendAsync(
- new HttpRequestMessage(HttpMethod.Post, $"invoices?storeId={user.StoreId}")
- {
- Content = new StringContent("{\"Price\": 5000, \"currency\": \"USD\"}", Encoding.UTF8,
- "application/json"),
- });
- Assert.Equal(403, (int)response.StatusCode);
-
- TestLogs.LogInformation(
- "No store without anyone can create invoice = 404 because the bitpay API can't know the storeid");
- response = await tester.PayTester.HttpClient.SendAsync(
- new HttpRequestMessage(HttpMethod.Post, $"invoices")
- {
- Content = new StringContent("{\"Price\": 5000, \"currency\": \"USD\"}", Encoding.UTF8,
- "application/json"),
- });
- Assert.Equal(404, (int)response.StatusCode);
-
- await user.ModifyPayment(p => p.AnyoneCanCreateInvoice = true);
-
- TestLogs.LogInformation("Bad store with anyone can create invoice = 403");
- response = await tester.PayTester.HttpClient.SendAsync(
- new HttpRequestMessage(HttpMethod.Post, $"invoices?storeId=badid")
- {
- Content = new StringContent("{\"Price\": 5000, \"currency\": \"USD\"}", Encoding.UTF8,
- "application/json"),
- });
- Assert.Equal(403, (int)response.StatusCode);
-
- TestLogs.LogInformation("Good store with anyone can create invoice = 200");
- response = await tester.PayTester.HttpClient.SendAsync(
- new HttpRequestMessage(HttpMethod.Post, $"invoices?storeId={user.StoreId}")
- {
- Content = new StringContent("{\"Price\": 5000, \"currency\": \"USD\"}", Encoding.UTF8,
- "application/json"),
- });
- Assert.Equal(200, (int)response.StatusCode);
- }
-
[Fact(Timeout = LongRunningTestTimeout)]
[Trait("Integration", "Integration")]
public async Task CanTweakRate()
diff --git a/BTCPayServer/Components/MainNav/Default.cshtml b/BTCPayServer/Components/MainNav/Default.cshtml
index af90e55..c2e5356 100644
--- a/BTCPayServer/Components/MainNav/Default.cshtml
+++ b/BTCPayServer/Components/MainNav/Default.cshtml
@@ -49,9 +49,7 @@
<li class="nav-item nav-item-sub" permission="@Policies.CanViewStoreSettings">
<a layout-menu-item="@(nameof(StoreNavPages.CheckoutAppearance))" asp-controller="UIStores" asp-action="CheckoutAppearance" asp-route-storeId="@Model.Store.Id" text-translate="true">Checkout Appearance</a>
</li>
- <li class="nav-item nav-item-sub" permission="@Policies.CanViewStoreSettings">
- <a layout-menu-item="@nameof(StoreNavPages.Tokens)" asp-controller="UIStores" asp-action="ListTokens" asp-route-storeId="@Model.Store.Id" text-translate="true">Access Tokens</a>
- </li>
+
<li class="nav-item nav-item-sub" permission="@Policies.CanViewStoreSettings">
<a layout-menu-item="@(nameof(StoreNavPages.Users))" asp-controller="UIStores" asp-action="StoreUsers" asp-route-storeId="@Model.Store.Id" text-translate="true">Users</a>
</li>
@@ -70,6 +68,7 @@
<li class="nav-item nav-item-sub" permission="@Policies.CanViewStoreSettings">
<a layout-menu-item="@(nameof(StoreNavPages.Forms))" asp-controller="UIForms" asp-action="FormsList" asp-route-storeId="@Model.Store.Id" text-translate="true">Forms</a>
</li>
+ <vc:ui-extension-point location="store-category-nav" model="@Model"/>
}
<vc:ui-extension-point location="store-nav" model="@Model"/>
</ul>
diff --git a/BTCPayServer/Controllers/BitpayAccessTokenController.cs b/BTCPayServer/Controllers/BitpayAccessTokenController.cs
deleted file mode 100644
index 205cffb..0000000
--- a/BTCPayServer/Controllers/BitpayAccessTokenController.cs
+++ /dev/null
@@ -1,91 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Threading.Tasks;
-using BTCPayServer.Abstractions.Constants;
-using BTCPayServer.Filters;
-using BTCPayServer.Models;
-using BTCPayServer.Security.Bitpay;
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Mvc;
-
-namespace BTCPayServer.Controllers
-{
- [Authorize(AuthenticationSchemes = AuthenticationSchemes.Bitpay)]
- [BitpayAPIConstraint()]
- public class BitpayAccessTokenController : Controller
- {
- readonly TokenRepository _TokenRepository;
- public BitpayAccessTokenController(TokenRepository tokenRepository)
- {
- _TokenRepository = tokenRepository ?? throw new ArgumentNullException(nameof(tokenRepository));
- }
- [HttpGet]
- [Route("tokens")]
- public async Task<GetTokensResponse> Tokens()
- {
- var tokens = await _TokenRepository.GetTokens(this.User.GetSIN());
- return new GetTokensResponse(tokens);
- }
-
- [HttpPost]
- [Route("tokens")]
- [AllowAnonymous]
- public async Task<DataWrapper<List<PairingCodeResponse>>> Tokens([FromBody] TokenRequest request)
- {
- if (request == null)
- throw new BitpayHttpException(400, "The request body is missing");
- PairingCodeEntity pairingEntity = null;
- if (string.IsNullOrEmpty(request.PairingCode))
- {
- if (string.IsNullOrEmpty(request.Id) || !NBitpayClient.Extensions.BitIdExtensions.ValidateSIN(request.Id))
- throw new BitpayHttpException(400, "'id' property is required");
-
- var pairingCode = await _TokenRepository.CreatePairingCodeAsync();
- await _TokenRepository.PairWithSINAsync(pairingCode, request.Id);
- pairingEntity = await _TokenRepository.UpdatePairingCode(new PairingCodeEntity()
- {
- Id = pairingCode,
- Label = request.Label
- });
-
- }
- else
- {
- var sin = this.User.GetSIN() ?? request.Id;
- if (string.IsNullOrEmpty(sin) || !NBitpayClient.Extensions.BitIdExtensions.ValidateSIN(sin))
- throw new BitpayHttpException(400, "'id' property is required, alternatively, use BitId");
-
- pairingEntity = await _TokenRepository.GetPairingAsync(request.PairingCode);
- if (pairingEntity == null)
- throw new BitpayHttpException(404, "The specified pairingCode is not found");
- pairingEntity.SIN = sin;
-
- if (string.IsNullOrEmpty(pairingEntity.Label) && !string.IsNullOrEmpty(request.Label))
- {
- pairingEntity.Label = request.Label;
- await _TokenRepository.UpdatePairingCode(pairingEntity);
- }
-
- var result = await _TokenRepository.PairWithSINAsync(request.PairingCode, sin);
- if (result != PairingResult.Complete && result != PairingResult.Partial)
- throw new BitpayHttpException(400, $"Error while pairing ({result})");
-
- }
-
- var pairingCodes = new List<PairingCodeResponse>
- {
- new PairingCodeResponse()
- {
- Policies = new Newtonsoft.Json.Linq.JArray(),
- PairingCode = pairingEntity.Id,
- PairingExpiration = pairingEntity.Expiration,
- DateCreated = pairingEntity.CreatedTime,
- Facade = "merchant",
- Token = pairingEntity.TokenValue,
- Label = pairingEntity.Label
- }
- };
- return DataWrapper.Create(pairingCodes);
- }
- }
-}
diff --git a/BTCPayServer/Controllers/BitpayInvoiceController.cs b/BTCPayServer/Controllers/BitpayInvoiceController.cs
deleted file mode 100644
index 16e1e4c..0000000
--- a/BTCPayServer/Controllers/BitpayInvoiceController.cs
+++ /dev/null
@@ -1,234 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Threading;
-using System.Threading.Tasks;
-using BTCPayServer.Abstractions.Constants;
-using BTCPayServer.Abstractions.Extensions;
-using BTCPayServer.Client;
-using BTCPayServer.Client.Models;
-using BTCPayServer.Data;
-using BTCPayServer.Filters;
-using BTCPayServer.ModelBinders;
-using BTCPayServer.Models;
-using BTCPayServer.Payments;
-using BTCPayServer.Security.Greenfield;
-using BTCPayServer.Services.Invoices;
-using BTCPayServer.Services.Rates;
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Mvc;
-using NBitpayClient;
-using StoreData = BTCPayServer.Data.StoreData;
-
-namespace BTCPayServer.Controllers
-{
- [BitpayAPIConstraint]
- [Authorize(Policies.CanCreateInvoice, AuthenticationSchemes = AuthenticationSchemes.Bitpay)]
- public class BitpayInvoiceController : Controller
- {
- private readonly UIInvoiceController _InvoiceController;
- private readonly Dictionary<PaymentMethodId, IPaymentMethodBitpayAPIExtension> _bitpayExtensions;
- private readonly CurrencyNameTable _currencyNameTable;
- private readonly InvoiceRepository _InvoiceRepository;
-
- public BitpayInvoiceController(UIInvoiceController invoiceController,
- Dictionary<PaymentMethodId, IPaymentMethodBitpayAPIExtension> bitpayExtensions,
- CurrencyNameTable currencyNameTable,
- InvoiceRepository invoiceRepository)
- {
- _InvoiceController = invoiceController;
- _bitpayExtensions = bitpayExtensions;
- _currencyNameTable = currencyNameTable;
- _InvoiceRepository = invoiceRepository;
- }
-
- [HttpPost]
- [Route("invoices")]
- [MediaTypeConstraint("application/json")]
- public async Task<DataWrapper<InvoiceResponse>> CreateInvoice([FromBody] BitpayCreateInvoiceRequest invoice, CancellationToken cancellationToken)
- {
- if (invoice == null)
- throw new BitpayHttpException(400, "Invalid invoice");
- return await CreateInvoiceCore(invoice, HttpContext.GetStoreData(), HttpContext.Request.GetAbsoluteRoot(), cancellationToken: cancellationToken);
- }
-
- [HttpGet]
- [Route("invoices/{id}")]
- public async Task<DataWrapper<InvoiceResponse>> GetInvoice(string id)
- {
- var invoice = (await _InvoiceRepository.GetInvoices(new InvoiceQuery()
- {
- InvoiceId = new[] { id },
- StoreId = new[] { HttpContext.GetStoreData().Id }
- })).FirstOrDefault();
- if (invoice == null)
- throw new BitpayHttpException(404, "Object not found");
- return new DataWrapper<InvoiceResponse>(invoice.EntityToDTO(_bitpayExtensions, Url, _currencyNameTable));
- }
- [HttpGet]
- [Route("invoices")]
- public async Task<IActionResult> GetInvoices(
- string token,
- [ModelBinder(typeof(BitpayDateTimeOffsetModelBinder))]
- DateTimeOffset? dateStart = null,
- [ModelBinder(typeof(BitpayDateTimeOffsetModelBinder))]
- DateTimeOffset? dateEnd = null,
- string orderId = null,
- string itemCode = null,
- string status = null,
- int? limit = null,
- int? offset = null)
- {
- if (User.Identity?.AuthenticationType == Security.Bitpay.BitpayAuthenticationTypes.Anonymous)
- return Forbid(Security.Bitpay.BitpayAuthenticationTypes.Anonymous);
- if (dateEnd != null)
- dateEnd = dateEnd.Value + TimeSpan.FromDays(1); //Should include the end day
-
- var query = new InvoiceQuery()
- {
- Take = limit,
- Skip = offset,
- EndDate = dateEnd,
- StartDate = dateStart,
- OrderId = orderId == null ? null : new[] { orderId },
- ItemCode = itemCode == null ? null : new[] { itemCode },
- Status = status == null ? null : new[] { status },
- StoreId = new[] { this.HttpContext.GetStoreData().Id }
- };
-
- var entities = (await _InvoiceRepository.GetInvoices(query))
- .Select((o) => o.EntityToDTO(_bitpayExtensions, Url, _currencyNameTable)).ToArray();
-
- return Json(DataWrapper.Create(entities));
- }
-
- internal async Task<DataWrapper<InvoiceResponse>> CreateInvoiceCore(BitpayCreateInvoiceRequest invoice,
- StoreData store, string serverUrl, List<string> additionalTags = null,
- CancellationToken cancellationToken = default, Action<InvoiceEntity> entityManipulator = null)
- {
- var entity = await CreateInvoiceCoreRaw(invoice, store, serverUrl, additionalTags, cancellationToken, entityManipulator);
- var resp = entity.EntityToDTO(_bitpayExtensions, Url, _currencyNameTable);
- return new DataWrapper<InvoiceResponse>(resp) { Facade = "pos/invoice" };
- }
-
- internal async Task<InvoiceEntity> CreateInvoiceCoreRaw(BitpayCreateInvoiceRequest invoice, StoreData store, string serverUrl, List<string> additionalTags = null, CancellationToken cancellationToken = default, Action<InvoiceEntity> entityManipulator = null)
- {
- var storeBlob = store.GetStoreBlob();
- var entity = _InvoiceRepository.CreateNewInvoice(store.Id);
- entity.ExpirationTime = invoice.ExpirationTime is { } v ? v : entity.InvoiceTime + storeBlob.InvoiceExpiration;
- entity.MonitoringExpiration = entity.ExpirationTime + storeBlob.MonitoringExpiration;
- if (entity.ExpirationTime - TimeSpan.FromSeconds(30.0) < entity.InvoiceTime)
- {
- throw new BitpayHttpException(400, "The expirationTime is set too soon");
- }
- if (entity.Price < 0.0m)
- {
- throw new BitpayHttpException(400, "The price should be 0 or more.");
- }
- if (entity.Price > GreenfieldConstants.MaxAmount)
- {
- throw new BitpayHttpException(400, $"The price should less than {GreenfieldConstants.MaxAmount}.");
- }
- entity.Metadata.OrderId = invoice.OrderId;
- entity.Metadata.PosDataLegacy = invoice.PosData;
- entity.ServerUrl = serverUrl;
- entity.FullNotifications = invoice.FullNotifications || invoice.ExtendedNotifications;
- entity.ExtendedNotifications = invoice.ExtendedNotifications;
- entity.NotificationURLTemplate = invoice.NotificationURL;
- entity.NotificationEmail = invoice.NotificationEmail;
- if (additionalTags != null)
- entity.InternalTags.AddRange(additionalTags);
- FillBuyerInfo(invoice, entity);
-
- var price = invoice.Price;
- entity.Metadata.ItemCode = invoice.ItemCode;
- entity.Metadata.ItemDesc = invoice.ItemDesc;
- entity.Metadata.Physical = invoice.Physical;
- entity.Metadata.TaxIncluded = invoice.TaxIncluded;
- entity.Currency = invoice.Currency;
- if (price is { } vv)
- {
- entity.Price = vv;
- entity.Type = InvoiceType.Standard;
- }
- else
- {
- entity.Price = 0m;
- entity.Type = InvoiceType.TopUp;
- }
-
- entity.StoreSupportUrl = storeBlob.StoreSupportUrl;
- entity.RedirectURLTemplate = invoice.RedirectURL ?? store.StoreWebsite;
- entity.RedirectAutomatically =
- invoice.RedirectAutomatically.GetValueOrDefault(storeBlob.RedirectAutomatically);
- entity.SpeedPolicy = ParseSpeedPolicy(invoice.TransactionSpeed, store.SpeedPolicy);
-
- IPaymentFilter excludeFilter = null;
- if (invoice.PaymentCurrencies?.Any() is true)
- {
- invoice.SupportedTransactionCurrencies ??=
- new Dictionary<string, InvoiceSupportedTransactionCurrency>();
- foreach (string paymentCurrency in invoice.PaymentCurrencies)
- {
- invoice.SupportedTransactionCurrencies.TryAdd(paymentCurrency,
- new InvoiceSupportedTransactionCurrency() { Enabled = true });
- }
- }
- if (invoice.SupportedTransactionCurrencies != null && invoice.SupportedTransactionCurrencies.Count != 0)
- {
- var supportedTransactionCurrencies = invoice.SupportedTransactionCurrencies
- .Where(c => c.Value.Enabled)
- .Select(c => PaymentMethodId.TryParse(c.Key, out var p) ? p : null)
- .Where(c => c != null)
- .ToHashSet();
- excludeFilter = PaymentFilter.Where(p => !supportedTransactionCurrencies.Contains(p));
- }
- entity.PaymentTolerance = storeBlob.PaymentTolerance;
- if (invoice.DefaultPaymentMethod is not null && PaymentMethodId.TryParse(invoice.DefaultPaymentMethod, out var defaultPaymentMethod))
- {
- entity.DefaultPaymentMethod = defaultPaymentMethod;
- }
-
- return await _InvoiceController.CreateInvoiceCoreRaw(entity, store, excludeFilter, null, cancellationToken, entityManipulator);
- }
-
- private void FillBuyerInfo(BitpayCreateInvoiceRequest req, InvoiceEntity invoiceEntity)
- {
- var buyerInformation = invoiceEntity.Metadata;
- buyerInformation.BuyerAddress1 = req.BuyerAddress1;
- buyerInformation.BuyerAddress2 = req.BuyerAddress2;
- buyerInformation.BuyerCity = req.BuyerCity;
- buyerInformation.BuyerCountry = req.BuyerCountry;
- buyerInformation.BuyerEmail = req.BuyerEmail;
- buyerInformation.BuyerName = req.BuyerName;
- buyerInformation.BuyerPhone = req.BuyerPhone;
- buyerInformation.BuyerState = req.BuyerState;
- buyerInformation.BuyerZip = req.BuyerZip;
- var buyer = req.Buyer;
- if (buyer == null)
- return;
- buyerInformation.BuyerAddress1 ??= buyer.Address1;
- buyerInformation.BuyerAddress2 ??= buyer.Address2;
- buyerInformation.BuyerCity ??= buyer.City;
- buyerInformation.BuyerCountry ??= buyer.country;
- buyerInformation.BuyerEmail ??= buyer.email;
- buyerInformation.BuyerName ??= buyer.Name;
- buyerInformation.BuyerPhone ??= buyer.phone;
- buyerInformation.BuyerState ??= buyer.State;
- buyerInformation.BuyerZip ??= buyer.zip;
- }
- private SpeedPolicy ParseSpeedPolicy(string transactionSpeed, SpeedPolicy defaultPolicy)
- {
- if (transactionSpeed == null)
- return defaultPolicy;
- var mappings = new Dictionary<string, SpeedPolicy>();
- mappings.Add("low", SpeedPolicy.LowSpeed);
- mappings.Add("low-medium", SpeedPolicy.LowMediumSpeed);
- mappings.Add("medium", SpeedPolicy.MediumSpeed);
- mappings.Add("high", SpeedPolicy.HighSpeed);
- if (!mappings.TryGetValue(transactionSpeed, out SpeedPolicy policy))
- policy = defaultPolicy;
- return policy;
- }
- }
-}
diff --git a/BTCPayServer/Controllers/BitpayRateController.cs b/BTCPayServer/Controllers/BitpayRateController.cs
deleted file mode 100644
index 97bc98e..0000000
--- a/BTCPayServer/Controllers/BitpayRateController.cs
+++ /dev/null
@@ -1,187 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Globalization;
-using System.Linq;
-using System.Text;
-using System.Threading;
-using System.Threading.Tasks;
-using BTCPayServer.Abstractions.Constants;
-using BTCPayServer.Data;
-using BTCPayServer.Filters;
-using BTCPayServer.Models;
-using BTCPayServer.Payments;
-using BTCPayServer.Rating;
-using BTCPayServer.Security;
-using BTCPayServer.Services.Invoices;
-using BTCPayServer.Services.Rates;
-using BTCPayServer.Services.Stores;
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Cors;
-using Microsoft.AspNetCore.Mvc;
-using Newtonsoft.Json;
-
-namespace BTCPayServer.Controllers
-{
- [EnableCors(CorsPolicies.All)]
- [Authorize(Policy = ServerPolicies.CanGetRates.Key, AuthenticationSchemes = AuthenticationSchemes.Bitpay)]
- public class BitpayRateController : Controller
- {
-
- readonly RateFetcher _rateProviderFactory;
- readonly CurrencyNameTable _currencyNameTable;
- private readonly DefaultRulesCollection _defaultRules;
- private readonly PaymentMethodHandlerDictionary _handlers;
- readonly StoreRepository _storeRepo;
- private readonly InvoiceRepository _invoiceRepository;
-
- private StoreData CurrentStore => HttpContext.GetStoreData();
-
- public BitpayRateController(
- RateFetcher rateProviderFactory,
- StoreRepository storeRepo,
- InvoiceRepository invoiceRepository,
- CurrencyNameTable currencyNameTable,
- DefaultRulesCollection defaultRules,
- PaymentMethodHandlerDictionary handlers)
- {
- _rateProviderFactory = rateProviderFactory ?? throw new ArgumentNullException(nameof(rateProviderFactory));
- _storeRepo = storeRepo;
- _invoiceRepository = invoiceRepository;
- _currencyNameTable = currencyNameTable ?? throw new ArgumentNullException(nameof(currencyNameTable));
- _defaultRules = defaultRules;
- _handlers = handlers;
- }
-
- [Route("rates/{baseCurrency}")]
- [HttpGet]
- [BitpayAPIConstraint]
- public async Task<IActionResult> GetBaseCurrencyRates(string baseCurrency, string cryptoCode = null, CancellationToken cancellationToken = default)
- {
- var inv = _invoiceRepository.CreateNewInvoice(CurrentStore.Id);
- inv.Currency = baseCurrency;
- var ctx = new InvoiceCreationContext(CurrentStore, CurrentStore.GetStoreBlob(), inv, new Logging.InvoiceLogs(), _handlers, null);
- ctx.SetLazyActivation(true);
- await ctx.BeforeFetchingRates();
- var currencyCodes = ctx
- .PaymentMethodContexts
- .SelectMany(c => c.Value.RequiredRates)
- .Where(c => c.Left.Equals(baseCurrency, StringComparison.OrdinalIgnoreCase))
- .Select(c => c.Right)
- .ToHashSet();
- var currencypairs = BuildCurrencyPairs(currencyCodes, baseCurrency);
-
- var result = await GetRates2(currencypairs, null, cryptoCode, cancellationToken);
- var rates = (result as JsonResult)?.Value as Rate[];
- return rates == null ? result : Json(new DataWrapper<Rate[]>(rates));
- }
-
- [HttpGet("rates/{baseCurrency}/{currency}")]
- [BitpayAPIConstraint]
- public async Task<IActionResult> GetCurrencyPairRate(string baseCurrency, string currency, string cryptoCode = null, CancellationToken cancellationToken = default)
- {
- var result = await GetRates2($"{baseCurrency}_{currency}", null, cryptoCode, cancellationToken);
- return (result as JsonResult)?.Value is not Rate[] rates
- ? result
- : Json(new DataWrapper<Rate>(rates.First()));
- }
-
- [HttpGet("rates")]
- [BitpayAPIConstraint]
- public async Task<IActionResult> GetRates(string currencyPairs, string storeId = null, string cryptoCode = null, CancellationToken cancellationToken = default)
- {
- var result = await GetRates2(currencyPairs, storeId, cryptoCode, cancellationToken);
- return (result as JsonResult)?.Value is not Rate[] rates
- ? result
- : Json(new DataWrapper<Rate[]>(rates));
- }
-
- [AllowAnonymous]
- [HttpGet("api/rates")]
- public async Task<IActionResult> GetRates2(string currencyPairs, string storeId, string cryptoCode = null, CancellationToken cancellationToken = default)
- {
- var store = CurrentStore ?? await _storeRepo.FindStore(storeId);
- if (store == null)
- {
- var err = Json(new BitpayErrorsModel { Error = "Store not found" });
- err.StatusCode = 404;
- return err;
- }
- if (currencyPairs == null)
- {
- var blob = store.GetStoreBlob();
- currencyPairs = blob.GetDefaultCurrencyPairString();
- if (string.IsNullOrEmpty(currencyPairs) && !string.IsNullOrWhiteSpace(cryptoCode))
- {
- currencyPairs = $"{blob.DefaultCurrency}_{cryptoCode}".ToUpperInvariant();
- }
- if (string.IsNullOrEmpty(currencyPairs))
- {
- var result = Json(new BitpayErrorsModel() { Error = "You need to setup the default currency pairs in 'Store Settings / Rates' or specify 'currencyPairs' query parameter (eg. BTC_USD,LTC_CAD)." });
- result.StatusCode = 400;
- return result;
- }
- }
-
- var rules = store.GetStoreBlob().GetRateRules(_defaultRules);
- var pairs = new HashSet<CurrencyPair>();
- foreach (var currency in currencyPairs.Split(','))
- {
- if (!CurrencyPair.TryParse(currency, out var pair))
- {
- var result = Json(new BitpayErrorsModel() { Error = $"Currency pair {currency} uncorrectly formatted" });
- result.StatusCode = 400;
- return result;
- }
- pairs.Add(pair);
- }
-
- var fetching = _rateProviderFactory.FetchRates(pairs, rules, new StoreIdRateContext(storeId), cancellationToken);
- await Task.WhenAll(fetching.Select(f => f.Value).ToArray());
- return Json(pairs
- .Select(r => (Pair: r, Value: fetching[r].GetAwaiter().GetResult().BidAsk?.Bid))
- .Where(r => r.Value.HasValue)
- .Select(r =>
- new Rate
- {
- CryptoCode = r.Pair.Left,
- Code = r.Pair.Right,
- CurrencyPair = r.Pair.ToString(),
- Name = _currencyNameTable.GetCurrencyData(r.Pair.Right, true).Name,
- Value = r.Value.Value
- }).Where(n => n.Name != null).ToArray());
- }
-
- private static string BuildCurrencyPairs(IEnumerable<string> currencyCodes, string baseCrypto)
- {
- var currencyPairsBuilder = new StringBuilder();
- bool first = true;
- foreach (var currencyCode in currencyCodes)
- {
- if (!first)
- currencyPairsBuilder.Append(',');
- first = false;
- currencyPairsBuilder.Append(CultureInfo.InvariantCulture, $"{baseCrypto}_{currencyCode}");
- }
- return currencyPairsBuilder.ToString();
- }
-
- public class Rate
- {
-
- [JsonProperty(PropertyName = "name")]
- public string Name { get; set; }
-
- [JsonProperty(PropertyName = "cryptoCode")]
- public string CryptoCode { get; set; }
-
- [JsonProperty(PropertyName = "currencyPair")]
- public string CurrencyPair { get; set; }
-
- [JsonProperty(PropertyName = "code")]
- public string Code { get; set; }
-
- [JsonProperty(PropertyName = "rate")]
- public decimal Value { get; set; }
- }
- }
-}
diff --git a/BTCPayServer/Controllers/UIInvoiceController.UI.cs b/BTCPayServer/Controllers/UIInvoiceController.UI.cs
index 4cfb2f7..2e0beac 100644
--- a/BTCPayServer/Controllers/UIInvoiceController.UI.cs
+++ b/BTCPayServer/Controllers/UIInvoiceController.UI.cs
@@ -600,7 +600,6 @@ namespace BTCPayServer.Controllers
[HttpPost("invoices/{invoiceId}/archive")]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanViewInvoices)]
- [BitpayAPIConstraint(false)]
public async Task<IActionResult> ToggleArchive(string invoiceId)
{
var invoice = (await _InvoiceRepository.GetInvoices(new InvoiceQuery
@@ -1056,7 +1055,6 @@ namespace BTCPayServer.Controllers
[HttpGet("/stores/{storeId}/invoices")]
[HttpGet("invoices")]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanViewInvoices)]
- [BitpayAPIConstraint(false)]
public async Task<IActionResult> ListInvoices(InvoicesModel? model = null)
{
model = this.ParseListQuery(model ?? new InvoicesModel());
@@ -1147,7 +1145,6 @@ namespace BTCPayServer.Controllers
[HttpGet("/stores/{storeId}/invoices/create")]
[HttpGet("invoices/create")]
[Authorize(Policy = Policies.CanCreateInvoice, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- [BitpayAPIConstraint(false)]
public async Task<IActionResult> CreateInvoice(InvoicesModel? model = null)
{
if (string.IsNullOrEmpty(model?.StoreId))
@@ -1179,7 +1176,6 @@ namespace BTCPayServer.Controllers
[HttpPost("/stores/{storeId}/invoices/create")]
[HttpPost("invoices/create")]
[Authorize(Policy = Policies.CanCreateInvoice, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- [BitpayAPIConstraint(false)]
public async Task<IActionResult> CreateInvoice(CreateInvoiceModel model, CancellationToken cancellationToken)
{
var store = HttpContext.GetStoreData();
@@ -1266,7 +1262,6 @@ namespace BTCPayServer.Controllers
[Route("invoices/{invoiceId}/changestate/{newState}")]
[Route("stores/{storeId}/invoices/{invoiceId}/changestate/{newState}")]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanViewInvoices)]
- [BitpayAPIConstraint(false)]
public async Task<IActionResult> ChangeInvoiceState(string invoiceId, string newState)
{
var invoice = (await _InvoiceRepository.GetInvoices(new InvoiceQuery
diff --git a/BTCPayServer/Controllers/UIInvoiceController.cs b/BTCPayServer/Controllers/UIInvoiceController.cs
index 3781e05..e2c6b01 100644
--- a/BTCPayServer/Controllers/UIInvoiceController.cs
+++ b/BTCPayServer/Controllers/UIInvoiceController.cs
@@ -15,7 +15,6 @@ using BTCPayServer.HostedServices;
using BTCPayServer.Logging;
using BTCPayServer.Payments;
using BTCPayServer.Rating;
-using BTCPayServer.Security;
using BTCPayServer.Security.Greenfield;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices;
@@ -36,7 +35,6 @@ using Microsoft.Extensions.Localization;
namespace BTCPayServer.Controllers
{
- [Filters.BitpayAPIConstraint(false)]
public partial class UIInvoiceController : Controller
{
readonly InvoiceRepository _InvoiceRepository;
diff --git a/BTCPayServer/Controllers/UINotificationsController.cs b/BTCPayServer/Controllers/UINotificationsController.cs
index 7149c7f..a311537 100644
--- a/BTCPayServer/Controllers/UINotificationsController.cs
+++ b/BTCPayServer/Controllers/UINotificationsController.cs
@@ -4,7 +4,6 @@ using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Client;
using BTCPayServer.Data;
-using BTCPayServer.Filters;
using BTCPayServer.Models.NotificationViewModels;
using BTCPayServer.Services.Notifications;
using BTCPayServer.Services.Stores;
@@ -14,7 +13,6 @@ using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.Controllers
{
- [BitpayAPIConstraint(false)]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanViewNotificationsForUser)]
[Route("notifications/{action:lowercase=Index}")]
public class UINotificationsController : Controller
diff --git a/BTCPayServer/Controllers/UIStoresController.Tokens.cs b/BTCPayServer/Controllers/UIStoresController.Tokens.cs
deleted file mode 100644
index 927d206..0000000
--- a/BTCPayServer/Controllers/UIStoresController.Tokens.cs
+++ /dev/null
@@ -1,259 +0,0 @@
-#nullable enable
-using System.Linq;
-using System.Threading.Tasks;
-using BTCPayServer.Abstractions.Constants;
-using BTCPayServer.Abstractions.Models;
-using BTCPayServer.Client;
-using BTCPayServer.Data;
-using BTCPayServer.Models;
-using BTCPayServer.Models.StoreViewModels;
-using BTCPayServer.Security.Bitpay;
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.AspNetCore.Mvc.Rendering;
-using NBitcoin;
-using NBitcoin.DataEncoders;
-
-namespace BTCPayServer.Controllers;
-
-public partial class UIStoresController
-{
- [HttpGet("{storeId}/tokens")]
- [Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public async Task<IActionResult> ListTokens()
- {
- var model = new TokensViewModel();
- var tokens = await _tokenRepository.GetTokensByStoreIdAsync(CurrentStore.Id);
- model.StoreNotConfigured = StoreNotConfigured;
- model.Tokens = tokens.Select(t => new TokenViewModel()
- {
- Label = t.Label,
- SIN = t.SIN,
- Id = t.Value
- }).ToArray();
-
- model.ApiKey = (await _tokenRepository.GetLegacyAPIKeys(CurrentStore.Id)).FirstOrDefault();
- model.EncodedApiKey = model.ApiKey == null ? "*API Key*" : Encoders.Base64.EncodeData(Encoders.ASCII.DecodeData(model.ApiKey));
- return View(model);
- }
-
- [HttpGet("{storeId}/tokens/{tokenId}/revoke")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public async Task<IActionResult> RevokeToken(string tokenId)
- {
- var token = await _tokenRepository.GetToken(tokenId);
- if (token == null || token.StoreId != CurrentStore.Id)
- return NotFound();
- return View("Confirm", new ConfirmModel(StringLocalizer["Revoke the token"], $"The access token with the label <strong>{_html.Encode(token.Label)}</strong> will be revoked. Do you wish to continue?", "Revoke"));
- }
-
- [HttpPost("{storeId}/tokens/{tokenId}/revoke")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public async Task<IActionResult> RevokeTokenConfirm(string tokenId)
- {
- var token = await _tokenRepository.GetToken(tokenId);
- if (token == null ||
- token.StoreId != CurrentStore.Id ||
- !await _tokenRepository.DeleteToken(tokenId))
- TempData[WellKnownTempData.ErrorMessage] = "Failure to revoke this token.";
- else
- TempData[WellKnownTempData.SuccessMessage] = "Token revoked";
- return RedirectToAction(nameof(ListTokens), new { storeId = token?.StoreId });
- }
-
- [HttpGet("{storeId}/tokens/{tokenId}")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public async Task<IActionResult> ShowToken(string tokenId)
- {
- var token = await _tokenRepository.GetToken(tokenId);
- if (token == null || token.StoreId != CurrentStore.Id)
- return NotFound();
- return View(token);
- }
-
- [HttpGet("{storeId}/tokens/create")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public IActionResult CreateToken(string storeId)
- {
- var model = new CreateTokenViewModel();
- ViewBag.HidePublicKey = storeId == null;
- ViewBag.ShowStores = storeId == null;
- model.StoreId = storeId;
- return View(model);
- }
-
- [HttpPost("{storeId}/tokens/create")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public async Task<IActionResult> CreateToken(string storeId, CreateTokenViewModel model)
- {
- if (!ModelState.IsValid)
- {
- return View(nameof(CreateToken), model);
- }
- model.Label ??= string.Empty;
- var userId = GetUserId();
- if (userId == null)
- return Challenge(AuthenticationSchemes.Cookie);
- var store = model.StoreId switch
- {
- null => CurrentStore,
- _ => await _storeRepo.FindStore(storeId, userId)
- };
- if (store == null)
- return Challenge(AuthenticationSchemes.Cookie);
- var tokenRequest = new TokenRequest()
- {
- Label = model.Label,
- Id = model.PublicKey == null ? null : NBitpayClient.Extensions.BitIdExtensions.GetBitIDSIN(new PubKey(model.PublicKey).Compress())
- };
-
- string? pairingCode;
- if (model.PublicKey == null)
- {
- tokenRequest.PairingCode = await _tokenRepository.CreatePairingCodeAsync();
- await _tokenRepository.UpdatePairingCode(new PairingCodeEntity()
- {
- Id = tokenRequest.PairingCode,
- Label = model.Label,
- });
- await _tokenRepository.PairWithStoreAsync(tokenRequest.PairingCode, store.Id);
- pairingCode = tokenRequest.PairingCode;
- }
- else
- {
- pairingCode = (await _tokenController.Tokens(tokenRequest)).Data[0].PairingCode;
- }
-
- GeneratedPairingCode = pairingCode;
- return RedirectToAction(nameof(RequestPairing), new
- {
- pairingCode,
- selectedStore = storeId
- });
- }
-
- [HttpGet("/api-tokens")]
- [AllowAnonymous]
- public async Task<IActionResult> CreateToken()
- {
- var userId = GetUserId();
- if (string.IsNullOrWhiteSpace(userId))
- return Challenge(AuthenticationSchemes.Cookie);
- var model = new CreateTokenViewModel();
- ViewBag.HidePublicKey = true;
- ViewBag.ShowStores = true;
- var stores = (await _storeRepo.GetStoresByUserId(userId)).Where(data => data.HasPermission(userId, Policies.CanModifyStoreSettings)).ToArray();
-
- model.Stores = new SelectList(stores, nameof(CurrentStore.Id), nameof(CurrentStore.StoreName));
- if (!model.Stores.Any())
- {
- TempData[WellKnownTempData.ErrorMessage] = "You need to be owner of at least one store before pairing";
- return RedirectToAction(nameof(UIHomeController.Index), "UIHome");
- }
- return View(model);
- }
-
- [HttpPost("/api-tokens")]
- [AllowAnonymous]
- public Task<IActionResult> CreateToken2(CreateTokenViewModel model)
- {
- return CreateToken(model.StoreId, model);
- }
-
- [HttpPost("{storeId}/tokens/apikey")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public async Task<IActionResult> GenerateAPIKey(string storeId, string command = "")
- {
- var store = HttpContext.GetStoreData();
- if (store == null)
- return NotFound();
- if (command == "revoke")
- {
- await _tokenRepository.RevokeLegacyAPIKeys(CurrentStore.Id);
- TempData[WellKnownTempData.SuccessMessage] = "API Key revoked";
- }
- else
- {
- await _tokenRepository.GenerateLegacyAPIKey(CurrentStore.Id);
- TempData[WellKnownTempData.SuccessMessage] = "API Key re-generated";
- }
-
- return RedirectToAction(nameof(ListTokens), new
- {
- storeId
- });
- }
-
- [HttpGet("/api-access-request")]
- [AllowAnonymous]
- public async Task<IActionResult> RequestPairing(string pairingCode, string? selectedStore = null)
- {
- var userId = GetUserId();
- if (userId == null)
- return Challenge(AuthenticationSchemes.Cookie);
-
- if (pairingCode == null)
- return NotFound();
-
- if (selectedStore != null)
- {
- var store = await _storeRepo.FindStore(selectedStore, userId);
- if (store == null)
- return NotFound();
- HttpContext.SetStoreData(store);
- }
-
- var pairing = await _tokenRepository.GetPairingAsync(pairingCode);
- if (pairing == null)
- {
- TempData[WellKnownTempData.ErrorMessage] = "Unknown pairing code";
- return RedirectToAction(nameof(UIHomeController.Index), "UIHome");
- }
-
- var stores = (await _storeRepo.GetStoresByUserId(userId)).Where(data => data.HasPermission(userId, Policies.CanModifyStoreSettings)).ToArray();
- return View(new PairingModel
- {
- Id = pairing.Id,
- Label = pairing.Label,
- SIN = pairing.SIN ?? "Server-Initiated Pairing",
- StoreId = selectedStore ?? stores.FirstOrDefault()?.Id,
- Stores = stores.Select(s => new PairingModel.StoreViewModel
- {
- Id = s.Id,
- Name = string.IsNullOrEmpty(s.StoreName) ? s.Id : s.StoreName
- }).ToArray()
- });
- }
-
- [HttpPost("/api-access-request")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public async Task<IActionResult> Pair(string pairingCode, string storeId)
- {
- if (pairingCode == null)
- return NotFound();
- var store = CurrentStore;
- var pairing = await _tokenRepository.GetPairingAsync(pairingCode);
- if (store == null || pairing == null)
- return NotFound();
-
- var pairingResult = await _tokenRepository.PairWithStoreAsync(pairingCode, store.Id);
- if (pairingResult == PairingResult.Complete || pairingResult == PairingResult.Partial)
- {
- var excludeFilter = store.GetStoreBlob().GetExcludedPaymentMethods();
- StoreNotConfigured = store.GetPaymentMethodConfigs(_handlers).All(p => excludeFilter.Match(p.Key));
- TempData[WellKnownTempData.SuccessMessage] = "Pairing is successful";
- if (pairingResult == PairingResult.Partial)
- TempData[WellKnownTempData.SuccessMessage] = $"Server initiated pairing code: {pairingCode}";
- return RedirectToAction(nameof(ListTokens), new
- {
- storeId = store.Id, pairingCode
- });
- }
-
- TempData[WellKnownTempData.ErrorMessage] = $"Pairing failed: {pairingResult}";
- return RedirectToAction(nameof(ListTokens), new
- {
- storeId = store.Id
- });
- }
-}
diff --git a/BTCPayServer/Controllers/UIStoresController.cs b/BTCPayServer/Controllers/UIStoresController.cs
index c2eb809..ee8d1a6 100644
--- a/BTCPayServer/Controllers/UIStoresController.cs
+++ b/BTCPayServer/Controllers/UIStoresController.cs
@@ -7,7 +7,6 @@ using BTCPayServer.Client;
using BTCPayServer.Configuration;
using BTCPayServer.Data;
using BTCPayServer.Models.StoreViewModels;
-using BTCPayServer.Security.Bitpay;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices;
@@ -37,9 +36,7 @@ public partial class UIStoresController : Controller
BTCPayServerOptions btcpayServerOptions,
BTCPayServerEnvironment btcpayEnv,
StoreRepository storeRepo,
- TokenRepository tokenRepo,
UserManager<ApplicationUser> userManager,
- BitpayAccessTokenController tokenController,
BTCPayWalletProvider walletProvider,
BTCPayNetworkProvider networkProvider,
RateFetcher rateFactory,
@@ -69,10 +66,8 @@ public partial class UIStoresController : Controller
{
_rateFactory = rateFactory;
_storeRepo = storeRepo;
- _tokenRepository = tokenRepo;
_userManager = userManager;
_langService = langService;
- _tokenController = tokenController;
_walletProvider = walletProvider;
_handlers = paymentMethodHandlerDictionary;
_policiesSettings = policiesSettings;
@@ -105,9 +100,7 @@ public partial class UIStoresController : Controller
private readonly BTCPayServerEnvironment _btcPayEnv;
private readonly BTCPayNetworkProvider _networkProvider;
private readonly BTCPayWalletProvider _walletProvider;
- private readonly BitpayAccessTokenController _tokenController;
private readonly StoreRepository _storeRepo;
- private readonly TokenRepository _tokenRepository;
private readonly UserManager<ApplicationUser> _userManager;
private readonly RateFetcher _rateFactory;
private readonly CurrencyNameTable _currencyNameTable;
@@ -133,12 +126,8 @@ public partial class UIStoresController : Controller
private readonly LightningClientFactoryService _lightningClientFactory;
private readonly StoreLabelRepository _storeLabelRepository;
- public string? GeneratedPairingCode { get; set; }
public IStringLocalizer StringLocalizer { get; }
- [TempData]
- private bool StoreNotConfigured { get; set; }
-
[AllowAnonymous]
[HttpGet("{storeId}/index")]
public async Task<IActionResult> Index(string storeId)
diff --git a/BTCPayServer/Extensions.cs b/BTCPayServer/Extensions.cs
index 89642e7..c3c1a2e 100644
--- a/BTCPayServer/Extensions.cs
+++ b/BTCPayServer/Extensions.cs
@@ -693,38 +693,6 @@ namespace BTCPayServer
return uri.DnsSafeHost.EndsWith(".onion", StringComparison.OrdinalIgnoreCase);
}
- public static string GetSIN(this ClaimsPrincipal principal)
- {
- return principal.Claims.Where(c => c.Type == Security.Bitpay.BitpayClaims.SIN).Select(c => c.Value).FirstOrDefault();
- }
-
- public static void SetIsBitpayAPI(this HttpContext ctx, bool value)
- {
- NBitcoin.Extensions.TryAdd(ctx.Items, "IsBitpayAPI", value);
- }
-
- public static bool GetIsBitpayAPI(this HttpContext ctx)
- {
- return ctx.Items.TryGetValue("IsBitpayAPI", out object obj) &&
- obj is bool b && b;
- }
-
- public static void SetBitpayAuth(this HttpContext ctx, (string Signature, String Id, String Authorization) value)
- {
- NBitcoin.Extensions.TryAdd(ctx.Items, "BitpayAuth", value);
- }
-
- public static bool TryGetBitpayAuth(this HttpContext ctx, out (string Signature, String Id, String Authorization) result)
- {
- if (ctx.Items.TryGetValue("BitpayAuth", out object obj))
- {
- result = ((string Signature, String Id, String Authorization))obj;
- return true;
- }
- result = default;
- return false;
- }
-
public static UserPrefsCookie GetUserPrefsCookie(this HttpContext ctx)
{
var prefCookie = new UserPrefsCookie();
diff --git a/BTCPayServer/Filters/OnlyMediaTypeAttribute.cs b/BTCPayServer/Filters/OnlyMediaTypeAttribute.cs
index 07794b4..e5572a5 100644
--- a/BTCPayServer/Filters/OnlyMediaTypeAttribute.cs
+++ b/BTCPayServer/Filters/OnlyMediaTypeAttribute.cs
@@ -47,25 +47,6 @@ namespace BTCPayServer.Filters
}
}
- public class BitpayAPIConstraintAttribute : Attribute, IActionConstraint
- {
- public BitpayAPIConstraintAttribute(bool isBitpayAPI = true)
- {
- IsBitpayAPI = isBitpayAPI;
- }
-
- public bool IsBitpayAPI
- {
- get; set;
- }
- public int Order => 100;
-
- public bool Accept(ActionConstraintContext context)
- {
- return context.RouteContext.HttpContext.GetIsBitpayAPI() == IsBitpayAPI;
- }
- }
-
public class AcceptMediaTypeConstraintAttribute : Attribute, IActionConstraint
{
public AcceptMediaTypeConstraintAttribute(string mediaType, bool expectedValue = true)
diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs
index 86483dd..9c54d01 100644
--- a/BTCPayServer/Hosting/BTCPayServerServices.cs
+++ b/BTCPayServer/Hosting/BTCPayServerServices.cs
@@ -6,11 +6,9 @@ using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Abstractions.Services;
-using BTCPayServer.Client;
using BTCPayServer.Common;
using BTCPayServer.Configuration;
using BTCPayServer.Controllers;
-using BTCPayServer.Controllers.Greenfield;
using BTCPayServer.Data;
using BTCPayServer.Data.Payouts.LightningLike;
using BTCPayServer.Forms;
@@ -34,7 +32,6 @@ using BTCPayServer.Plugins;
using BTCPayServer.Rating;
using BTCPayServer.Rating.Providers;
using BTCPayServer.Security;
-using BTCPayServer.Security.Bitpay;
using BTCPayServer.Security.Greenfield;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
@@ -75,8 +72,6 @@ using BTCPayServer.Payouts;
using ExchangeSharp;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Identity;
-using Microsoft.Extensions.Localization;
-using Microsoft.AspNetCore.Mvc.Localization;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.EntityFrameworkCore;
@@ -177,7 +172,6 @@ namespace BTCPayServer.Hosting
services.TryAddSingleton<InvoiceRepository>();
services.AddSingleton<PaymentService>();
services.AddSingleton<BTCPayServerEnvironment>();
- services.TryAddSingleton<TokenRepository>();
services.TryAddSingleton<WalletRepository>();
services.TryAddSingleton<EventAggregator>();
services.TryAddSingleton<PaymentRequestService>();
@@ -464,7 +458,6 @@ namespace BTCPayServer.Hosting
services.AddSingleton<IHostedService>(s => s.GetRequiredService<PaymentRequestStreamer>());
services.AddSingleton<IBackgroundJobClient, BackgroundJobClient>();
services.AddScoped<IAuthorizationHandler, CookieAuthorizationHandler>();
- services.AddScoped<IAuthorizationHandler, BitpayAuthorizationHandler>();
services.AddSingleton<INotificationHandler, NewVersionNotification.Handler>();
services.AddSingleton<INotificationHandler, NewUserRequiresApprovalNotification.Handler>();
@@ -489,7 +482,6 @@ namespace BTCPayServer.Hosting
services.TryAddSingleton<RateFetcher>();
services.TryAddScoped<IHttpContextAccessor, HttpContextAccessor>();
- services.AddTransient<BitpayAccessTokenController>();
services.AddTransient<UIInvoiceController>();
services.AddTransient<UIPaymentRequestController>();
services.AddSingleton<LabelService>();
@@ -773,14 +765,13 @@ namespace BTCPayServer.Hosting
options.AccessDeniedPath = "/errors/403";
options.LogoutPath = "/logout";
})
- .AddBitpayAuthentication()
.AddAPIKeyAuthentication();
}
public static IApplicationBuilder UsePayServer(this IApplicationBuilder app)
{
- app.UseMiddleware<GreenfieldMiddleware>();
- app.UseMiddleware<BTCPayMiddleware>();
+ app.UseMiddleware<SetCultureMiddleware>();
+ app.UseMiddleware<OnionLocationMiddleware>();
return app;
}
diff --git a/BTCPayServer/Hosting/BTCpayMiddleware.cs b/BTCPayServer/Hosting/BTCpayMiddleware.cs
deleted file mode 100644
index 8289117..0000000
--- a/BTCPayServer/Hosting/BTCpayMiddleware.cs
+++ /dev/null
@@ -1,163 +0,0 @@
-using System;
-using System.Globalization;
-using System.Linq;
-using System.Net.WebSockets;
-using System.Threading.Tasks;
-using BTCPayServer.Abstractions.Extensions;
-using BTCPayServer.Configuration;
-using BTCPayServer.Logging;
-using BTCPayServer.Models;
-using BTCPayServer.Services;
-using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.Http.Extensions;
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Primitives;
-using Newtonsoft.Json;
-
-namespace BTCPayServer.Hosting
-{
- public class BTCPayMiddleware
- {
- readonly RequestDelegate _Next;
- readonly BTCPayServerOptions _Options;
-
- public Logs Logs { get; }
-
- readonly BTCPayServerEnvironment _Env;
-
- public BTCPayMiddleware(RequestDelegate next,
- BTCPayServerOptions options,
- BTCPayServerEnvironment env,
- Logs logs)
- {
- _Env = env ?? throw new ArgumentNullException(nameof(env));
- _Next = next ?? throw new ArgumentNullException(nameof(next));
- _Options = options ?? throw new ArgumentNullException(nameof(options));
- Logs = logs;
- }
-
-
- public async Task Invoke(HttpContext httpContext)
- {
- CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
- CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture;
- try
- {
- var bitpayAuth = GetBitpayAuth(httpContext, out bool isBitpayAuth);
- var isBitpayAPI = IsBitpayAPI(httpContext, isBitpayAuth);
- if (isBitpayAPI && httpContext.Request.Method == "OPTIONS")
- {
- httpContext.Response.StatusCode = 200;
- httpContext.Response.SetHeader("Access-Control-Allow-Origin", "*");
- if (httpContext.Request.Headers.ContainsKey("Access-Control-Request-Headers"))
- {
- httpContext.Response.SetHeader("Access-Control-Allow-Headers", httpContext.Request.Headers["Access-Control-Request-Headers"].FirstOrDefault());
- }
- return; // We bypass MVC completely
- }
- httpContext.SetIsBitpayAPI(isBitpayAPI);
- if (isBitpayAPI)
- {
- httpContext.Response.SetHeader("Access-Control-Allow-Origin", "*");
- httpContext.SetBitpayAuth(bitpayAuth);
- await _Next(httpContext);
- return;
- }
-
- var isHtml = httpContext.Request.Headers.TryGetValue("Accept", out var accept)
- && accept.ToString().StartsWith("text/html", StringComparison.OrdinalIgnoreCase);
- var isModal = httpContext.Request.Query.TryGetValue("view", out var view)
- && view.ToString().Equals("modal", StringComparison.OrdinalIgnoreCase);
- if (!string.IsNullOrEmpty(_Env.OnionUrl) &&
- !httpContext.Request.IsOnion() &&
- isHtml &&
- !isModal)
- {
- var onionLocation = _Env.OnionUrl + httpContext.Request.GetEncodedPathAndQuery();
- httpContext.Response.SetHeader("Onion-Location", onionLocation);
- }
- }
- catch (WebSocketException)
- { }
- catch (UnauthorizedAccessException ex)
- {
- await HandleBitpayHttpException(httpContext, new BitpayHttpException(401, ex.Message));
- return;
- }
- catch (BitpayHttpException ex)
- {
- await HandleBitpayHttpException(httpContext, ex);
- return;
- }
- catch (Exception ex)
- {
- Logs.PayServer.LogCritical(new EventId(), ex, "Unhandled exception in BTCPayMiddleware");
- throw;
- }
- await _Next(httpContext);
- }
-
- private static (string Signature, String Id, String Authorization) GetBitpayAuth(HttpContext httpContext, out bool hasBitpayAuth)
- {
- httpContext.Request.Headers.TryGetValue("x-signature", out StringValues values);
- var sig = values.FirstOrDefault();
- httpContext.Request.Headers.TryGetValue("x-identity", out values);
- var id = values.FirstOrDefault();
- httpContext.Request.Headers.TryGetValue("Authorization", out values);
- var auth = values.FirstOrDefault();
- hasBitpayAuth = auth != null || (sig != null && id != null);
- return (sig, id, auth);
- }
-
- private bool IsBitpayAPI(HttpContext httpContext, bool bitpayAuth)
- {
- if (!httpContext.Request.Path.HasValue)
- return false;
-
- // In case of anyone can create invoice, the storeId can be set explicitly
- bitpayAuth |= httpContext.Request.Query.ContainsKey("storeid");
-
- var isJson = (httpContext.Request.ContentType ?? string.Empty).StartsWith("application/json", StringComparison.OrdinalIgnoreCase);
- var path = httpContext.Request.Path.Value;
- var method = httpContext.Request.Method;
- var isCors = method == "OPTIONS";
-
- if (
- (isCors || bitpayAuth) &&
- (path == "/invoices" || path == "/invoices/") &&
- (isCors || (method == "POST" && isJson)))
- return true;
-
- if (
- (isCors || bitpayAuth) &&
- (path == "/invoices" || path == "/invoices/") &&
- (isCors || method == "GET"))
- return true;
-
- if (
- path.StartsWith("/invoices/", StringComparison.OrdinalIgnoreCase) &&
- (isCors || method == "GET") &&
- (isCors || isJson || httpContext.Request.Query.ContainsKey("token")))
- return true;
-
- if (path.StartsWith("/rates", StringComparison.OrdinalIgnoreCase) &&
- (isCors || method == "GET"))
- return true;
-
- if (
- path.Equals("/tokens", StringComparison.Ordinal) &&
- (isCors || method == "GET" || method == "POST"))
- return true;
-
- return false;
- }
-
- private static async Task HandleBitpayHttpException(HttpContext httpContext, BitpayHttpException ex)
- {
- httpContext.Response.StatusCode = ex.StatusCode;
- httpContext.Response.ContentType = "application/json";
- var result = JsonConvert.SerializeObject(new BitpayErrorsModel(ex));
- await httpContext.Response.WriteAsync(result);
- }
- }
-}
diff --git a/BTCPayServer/Hosting/GreenfieldMiddleware.cs b/BTCPayServer/Hosting/GreenfieldMiddleware.cs
deleted file mode 100644
index 08257ef..0000000
--- a/BTCPayServer/Hosting/GreenfieldMiddleware.cs
+++ /dev/null
@@ -1,71 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Globalization;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-using BTCPayServer.Client.Models;
-using BTCPayServer.Security.Greenfield;
-using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.AspNetCore.Mvc.Infrastructure;
-using Microsoft.Extensions.Options;
-using Newtonsoft.Json;
-
-namespace BTCPayServer.Hosting
-{
- public class GreenfieldMiddleware
- {
- private readonly RequestDelegate _next;
- private readonly IOptions<MvcNewtonsoftJsonOptions> _mvcOptions;
-
- public GreenfieldMiddleware(RequestDelegate next, IOptions<MvcNewtonsoftJsonOptions> mvcOptions)
- {
- _next = next;
- _mvcOptions = mvcOptions;
- }
-
- public async Task Invoke(HttpContext httpContext)
- {
- await _next(httpContext);
- if (!httpContext.Response.HasStarted &&
- !IsJson(httpContext.Response.ContentType) &&
- !IsHtml(httpContext.Response.ContentType) &&
- !httpContext.GetIsBitpayAPI() &&
- (httpContext.Response.StatusCode == 401 || httpContext.Response.StatusCode == 403))
- {
- if (httpContext.Response.StatusCode == 403 &&
- httpContext.Items.TryGetValue(GreenfieldAuthorizationHandler.RequestedPermissionKey, out var p) &&
- p is string policy)
- {
- var outputObj = new GreenfieldPermissionAPIError(policy);
- await WriteError(httpContext, outputObj);
- }
- if (httpContext.Response.StatusCode == 401)
- {
- httpContext.Items.TryGetValue(APIKeysAuthenticationHandler.AuthFailureReason, out var reason);
- var reasonStr = reason as string ?? "Authentication is required for accessing this endpoint";
- var outputObj = new GreenfieldAPIError("unauthenticated", reasonStr);
- await WriteError(httpContext, outputObj);
- }
- }
- }
-
- private async Task WriteError(HttpContext httpContext, object outputObj)
- {
- string output = JsonConvert.SerializeObject(outputObj, _mvcOptions.Value.SerializerSettings);
- var outputBytes = new UTF8Encoding(false).GetBytes(output);
- httpContext.Response.Headers.Add("Content-Type", "application/json");
- httpContext.Response.Headers.Add("Content-Length", outputBytes.Length.ToString(CultureInfo.InvariantCulture));
- await httpContext.Response.Body.WriteAsync(outputBytes, 0, outputBytes.Length);
- }
- private bool IsHtml(string contentType)
- {
- return contentType?.StartsWith("text/html", StringComparison.OrdinalIgnoreCase) is true;
- }
- private bool IsJson(string contentType)
- {
- return contentType?.StartsWith("application/json", StringComparison.OrdinalIgnoreCase) is true;
- }
- }
-}
diff --git a/BTCPayServer/Hosting/OnionLocationMiddleware.cs b/BTCPayServer/Hosting/OnionLocationMiddleware.cs
new file mode 100644
index 0000000..b658c24
--- /dev/null
+++ b/BTCPayServer/Hosting/OnionLocationMiddleware.cs
@@ -0,0 +1,29 @@
+using System;
+using System.Threading.Tasks;
+using BTCPayServer.Abstractions.Extensions;
+using BTCPayServer.Services;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.Extensions;
+
+namespace BTCPayServer.Hosting;
+
+public class OnionLocationMiddleware(RequestDelegate next, BTCPayServerEnvironment env)
+{
+ public Task Invoke(HttpContext httpContext)
+ {
+ var isHtml = httpContext.Request.Headers.TryGetValue("Accept", out var accept)
+ && accept.ToString().StartsWith("text/html", StringComparison.OrdinalIgnoreCase);
+ var isModal = httpContext.Request.Query.TryGetValue("view", out var view)
+ && view.ToString().Equals("modal", StringComparison.OrdinalIgnoreCase);
+ if (!string.IsNullOrEmpty(env.OnionUrl) &&
+ !httpContext.Request.IsOnion() &&
+ isHtml &&
+ !isModal)
+ {
+ var onionLocation = env.OnionUrl + httpContext.Request.GetEncodedPathAndQuery();
+ httpContext.Response.SetHeader("Onion-Location", onionLocation);
+ }
+ return next(httpContext);
+ }
+
+}
diff --git a/BTCPayServer/Hosting/SetCultureMiddleware.cs b/BTCPayServer/Hosting/SetCultureMiddleware.cs
new file mode 100644
index 0000000..b26f5e9
--- /dev/null
+++ b/BTCPayServer/Hosting/SetCultureMiddleware.cs
@@ -0,0 +1,16 @@
+using System.Globalization;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Http;
+
+namespace BTCPayServer.Hosting;
+
+public class SetCultureMiddleware(RequestDelegate next)
+{
+
+ public async Task Invoke(HttpContext httpContext)
+ {
+ CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
+ CultureInfo.CurrentUICulture = CultureInfo.InvariantCulture;
+ await next(httpContext);
+ }
+}
diff --git a/BTCPayServer/Hosting/Startup.cs b/BTCPayServer/Hosting/Startup.cs
index 8d1b219..4dc62b7 100644
--- a/BTCPayServer/Hosting/Startup.cs
+++ b/BTCPayServer/Hosting/Startup.cs
@@ -332,7 +332,7 @@ namespace BTCPayServer.Hosting
app.UseExceptionHandler("/errors/{0}");
app.UsePayServer();
app.UseRouting();
- app.UseCors();
+ app.UseCors(CorsPolicies.All);
app.UseStaticFiles(new StaticFileOptions
{
diff --git a/BTCPayServer/Models/BitpayCreateInvoiceRequest.cs b/BTCPayServer/Models/BitpayCreateInvoiceRequest.cs
deleted file mode 100644
index a6f9d32..0000000
--- a/BTCPayServer/Models/BitpayCreateInvoiceRequest.cs
+++ /dev/null
@@ -1,87 +0,0 @@
-using System;
-using System.Collections.Generic;
-using NBitpayClient;
-using Newtonsoft.Json;
-
-namespace BTCPayServer.Models
-{
- public class BitpayCreateInvoiceRequest
- {
- [JsonProperty(PropertyName = "buyer")]
- public Buyer Buyer { get; set; }
- [JsonProperty(PropertyName = "buyerEmail", DefaultValueHandling = DefaultValueHandling.Ignore)]
- public string BuyerEmail { get; set; }
- [JsonProperty(PropertyName = "buyerCountry", DefaultValueHandling = DefaultValueHandling.Ignore)]
- public string BuyerCountry { get; set; }
- [JsonProperty(PropertyName = "buyerZip", DefaultValueHandling = DefaultValueHandling.Ignore)]
- public string BuyerZip { get; set; }
- [JsonProperty(PropertyName = "buyerState", DefaultValueHandling = DefaultValueHandling.Ignore)]
- public string BuyerState { get; set; }
- [JsonProperty(PropertyName = "buyerCity", DefaultValueHandling = DefaultValueHandling.Ignore)]
- public string BuyerCity { get; set; }
- [JsonProperty(PropertyName = "buyerAddress2", DefaultValueHandling = DefaultValueHandling.Ignore)]
- public string BuyerAddress2 { get; set; }
- [JsonProperty(PropertyName = "buyerAddress1", DefaultValueHandling = DefaultValueHandling.Ignore)]
- public string BuyerAddress1 { get; set; }
- [JsonProperty(PropertyName = "buyerName", DefaultValueHandling = DefaultValueHandling.Ignore)]
- public string BuyerName { get; set; }
- [JsonProperty(PropertyName = "physical", DefaultValueHandling = DefaultValueHandling.Ignore)]
- public bool Physical { get; set; }
- [JsonProperty(PropertyName = "redirectURL", DefaultValueHandling = DefaultValueHandling.Ignore)]
- public string RedirectURL { get; set; }
- [JsonProperty(PropertyName = "notificationURL", DefaultValueHandling = DefaultValueHandling.Ignore)]
- public string NotificationURL { get; set; }
- [JsonProperty(PropertyName = "extendedNotifications", DefaultValueHandling = DefaultValueHandling.Ignore)]
- public bool ExtendedNotifications { get; set; }
- [JsonProperty(PropertyName = "fullNotifications", DefaultValueHandling = DefaultValueHandling.Ignore)]
- public bool FullNotifications { get; set; }
- [JsonProperty(PropertyName = "transactionSpeed", DefaultValueHandling = DefaultValueHandling.Ignore)]
- public string TransactionSpeed { get; set; }
- [JsonProperty(PropertyName = "buyerPhone", DefaultValueHandling = DefaultValueHandling.Ignore)]
- public string BuyerPhone { get; set; }
- [JsonProperty(PropertyName = "posData", DefaultValueHandling = DefaultValueHandling.Ignore)]
- public string PosData { get; set; }
- [JsonProperty(PropertyName = "itemCode", DefaultValueHandling = DefaultValueHandling.Ignore)]
- public string ItemCode { get; set; }
- [JsonProperty(PropertyName = "itemDesc", DefaultValueHandling = DefaultValueHandling.Ignore)]
- public string ItemDesc { get; set; }
- [JsonProperty(PropertyName = "orderId", DefaultValueHandling = DefaultValueHandling.Ignore)]
- public string OrderId { get; set; }
- [JsonProperty(PropertyName = "currency", DefaultValueHandling = DefaultValueHandling.Ignore)]
- public string Currency { get; set; }
- [JsonProperty(PropertyName = "price", DefaultValueHandling = DefaultValueHandling.Ignore)]
- public decimal? Price { get; set; }
- [JsonProperty(PropertyName = "defaultPaymentMethod", DefaultValueHandling = DefaultValueHandling.Ignore)]
- public string DefaultPaymentMethod { get; set; }
- [JsonProperty(PropertyName = "notificationEmail", DefaultValueHandling = DefaultValueHandling.Ignore)]
- public string NotificationEmail { get; set; }
- [JsonConverter(typeof(DateTimeJsonConverter))]
- [JsonProperty(PropertyName = "expirationTime", DefaultValueHandling = DefaultValueHandling.Ignore)]
- public DateTimeOffset? ExpirationTime { get; set; }
- [JsonProperty(PropertyName = "status", DefaultValueHandling = DefaultValueHandling.Ignore)]
- public string Status { get; set; }
- [JsonProperty(PropertyName = "minerFees", DefaultValueHandling = DefaultValueHandling.Ignore)]
- public Dictionary<string, MinerFeeInfo> MinerFees { get; set; }
- [JsonProperty(PropertyName = "supportedTransactionCurrencies", DefaultValueHandling = DefaultValueHandling.Ignore)]
- public Dictionary<string, InvoiceSupportedTransactionCurrency> SupportedTransactionCurrencies { get; set; }
- [JsonProperty(PropertyName = "exchangeRates", DefaultValueHandling = DefaultValueHandling.Ignore)]
- public Dictionary<string, Dictionary<string, decimal>> ExchangeRates { get; set; }
- [JsonProperty(PropertyName = "refundable", DefaultValueHandling = DefaultValueHandling.Ignore)]
- public bool Refundable { get; set; }
- [JsonProperty(PropertyName = "taxIncluded", DefaultValueHandling = DefaultValueHandling.Ignore)]
- public decimal? TaxIncluded { get; set; }
- [JsonProperty(PropertyName = "nonce", DefaultValueHandling = DefaultValueHandling.Ignore)]
- public long Nonce { get; set; }
- [JsonProperty(PropertyName = "guid", DefaultValueHandling = DefaultValueHandling.Ignore)]
- public string Guid { get; set; }
- [JsonProperty(PropertyName = "token", DefaultValueHandling = DefaultValueHandling.Ignore)]
- public string Token { get; set; }
-
- [JsonProperty(PropertyName = "redirectAutomatically", DefaultValueHandling = DefaultValueHandling.Ignore)]
- public bool? RedirectAutomatically { get; set; }
-
- //Bitpay compatibility: create invoice in btcpay uses this instead of supportedTransactionCurrencies
- [JsonProperty(PropertyName = "paymentCurrencies", DefaultValueHandling = DefaultValueHandling.Ignore)]
- public IEnumerable<string> PaymentCurrencies { get; set; }
- }
-}
diff --git a/BTCPayServer/Models/BitpayErrorsModel.cs b/BTCPayServer/Models/BitpayErrorsModel.cs
deleted file mode 100644
index a51efa7..0000000
--- a/BTCPayServer/Models/BitpayErrorsModel.cs
+++ /dev/null
@@ -1,36 +0,0 @@
-using Newtonsoft.Json;
-
-namespace BTCPayServer.Models
-{
- public class BitpayErrorsModel
- {
- public BitpayErrorsModel()
- {
-
- }
- public BitpayErrorsModel(BitpayHttpException ex)
- {
- Error = ex.Message;
- }
-
- [JsonProperty("errors", DefaultValueHandling = DefaultValueHandling.Ignore)]
- public BitpayErrorModel[] Errors
- {
- get; set;
- }
- [JsonProperty("error", DefaultValueHandling = DefaultValueHandling.Ignore)]
- public string Error
- {
- get; set;
- }
- }
-
- public class BitpayErrorModel
- {
- [JsonProperty("error")]
- public string Error
- {
- get; set;
- }
- }
-}
diff --git a/BTCPayServer/Models/GetTokensResponse.cs b/BTCPayServer/Models/GetTokensResponse.cs
deleted file mode 100644
index 72dc6dd..0000000
--- a/BTCPayServer/Models/GetTokensResponse.cs
+++ /dev/null
@@ -1,50 +0,0 @@
-using System;
-using System.IO;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-using BTCPayServer.Security.Bitpay;
-using Microsoft.AspNetCore.Mvc;
-using Newtonsoft.Json;
-using Newtonsoft.Json.Linq;
-
-namespace BTCPayServer.Models
-{
- //{"data":[{"pos":"FfZ6WCa8TunAvPCpQZXkdBsoH4Yo18FyPaJ5X5qjrVVY"},{"pos/invoice":"H1pwwh2tMeSCri9rh5VvHWEHokGdf2EGtghfZkUEbeZv"},{"merchant":"89zEBr9orAc6wgybAABp8ioGcjYeFrUaZgMzjxNuqYty"},{"merchant/invoice":"8e7ijDxGfJsWXWgJuKXjjNgxnX1xpsBM8cTZCFnU7ehj"}]}
- public class GetTokensResponse : IActionResult
- {
- readonly BitTokenEntity[] _Tokens;
- public GetTokensResponse(BitTokenEntity[] tokens)
- {
- ArgumentNullException.ThrowIfNull(tokens);
- this._Tokens = tokens;
- }
-
- [JsonProperty(PropertyName = "data")]
- //{"pos":"FfZ6WCa8TunAvPCpQZXkdBsoH4Yo18FyPaJ5X5qjrVVY"}
- public JArray Data
- {
- get; set;
- }
-
- public async Task ExecuteResultAsync(ActionContext context)
- {
- JObject jobj = new JObject();
- JArray jarray = new JArray();
- jobj.Add("data", jarray);
- var token = _Tokens.FirstOrDefault();
- if (token != null)
- {
- JObject item = new JObject();
- jarray.Add(item);
- JProperty jProp = new JProperty("merchant");
- item.Add(jProp);
- jProp.Value = token.Value;
- }
- context.HttpContext.Response.Headers.Add("Content-Type", new Microsoft.Extensions.Primitives.StringValues("application/json"));
- var str = JsonConvert.SerializeObject(jobj);
- await using var writer = new StreamWriter(context.HttpContext.Response.Body, new UTF8Encoding(false), 1024 * 10, true);
- await writer.WriteAsync(str);
- }
- }
-}
diff --git a/BTCPayServer/Models/StoreViewModels/PairingModel.cs b/BTCPayServer/Models/StoreViewModels/PairingModel.cs
deleted file mode 100644
index 551c002..0000000
--- a/BTCPayServer/Models/StoreViewModels/PairingModel.cs
+++ /dev/null
@@ -1,43 +0,0 @@
-using System.ComponentModel.DataAnnotations;
-
-namespace BTCPayServer.Models.StoreViewModels
-{
- public class PairingModel
- {
- public class StoreViewModel
- {
- public string Name
- {
- get; set;
- }
- public string Id
- {
- get; set;
- }
- }
- public string Id
- {
- get; set;
- }
- public string Label
- {
- get; set;
- }
- public string SIN
- {
- get; set;
- }
- public StoreViewModel[] Stores
- {
- get;
- set;
- }
-
- [Display(Name = "Pair to")]
- [Required]
- public string StoreId
- {
- get; set;
- }
- }
-}
diff --git a/BTCPayServer/Models/StoreViewModels/TokensViewModel.cs b/BTCPayServer/Models/StoreViewModels/TokensViewModel.cs
deleted file mode 100644
index 11703ff..0000000
--- a/BTCPayServer/Models/StoreViewModels/TokensViewModel.cs
+++ /dev/null
@@ -1,61 +0,0 @@
-using System.ComponentModel.DataAnnotations;
-using BTCPayServer.Validation;
-using Microsoft.AspNetCore.Mvc.Rendering;
-
-namespace BTCPayServer.Models.StoreViewModels
-{
- public class CreateTokenViewModel
- {
- [Display(Name = "Public Key")]
- [PubKeyValidatorAttribute]
- public string PublicKey
- {
- get; set;
- }
-
- [Display(Name = "Label")]
- public string Label
- {
- get; set;
- }
-
- [Required]
- public string StoreId
- {
- get; set;
- }
-
- [Display(Name = "Store")]
- public SelectList Stores
- {
- get; set;
- }
- }
- public class TokenViewModel
- {
- public string Id
- {
- get; set;
- }
- public string Label
- {
- get; set;
- }
- public string SIN
- {
- get; set;
- }
- }
- public class TokensViewModel
- {
- public TokenViewModel[] Tokens
- {
- get; set;
- }
-
- [Display(Name = "API Key")]
- public string ApiKey { get; set; }
- public string EncodedApiKey { get; set; }
- public bool StoreNotConfigured { get; set; }
- }
-}
diff --git a/BTCPayServer/Plugins/Bitpay/BitpayEndpointSelectorPolicy.cs b/BTCPayServer/Plugins/Bitpay/BitpayEndpointSelectorPolicy.cs
new file mode 100644
index 0000000..137cf79
--- /dev/null
+++ b/BTCPayServer/Plugins/Bitpay/BitpayEndpointSelectorPolicy.cs
@@ -0,0 +1,75 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.AspNetCore.Routing.Matching;
+
+namespace BTCPayServer.Plugins.Bitpay;
+
+public class BitpayEndpointSelectorPolicy : MatcherPolicy, IEndpointSelectorPolicy
+{
+ public class BitpayEndpointMetadata : Attribute;
+
+ public bool AppliesToEndpoints(IReadOnlyList<Endpoint> endpoints)
+ => endpoints.Any(e => e.Metadata.GetMetadata<BitpayEndpointMetadata>() is not null);
+
+ public Task ApplyAsync(HttpContext httpContext, CandidateSet candidates)
+ {
+ var isBitpayAuth = httpContext.TryGetBitpayAuth(out _);
+ var isBitpayAPI = IsBitpayAPI(httpContext, isBitpayAuth);
+ for (var i = 0; i < candidates.Count; i++)
+ {
+ var bitpayEndpoint = candidates[i].Endpoint.Metadata.GetMetadata<BitpayEndpointMetadata>();
+ candidates.SetValidity(i, bitpayEndpoint is not null == isBitpayAPI);
+ }
+ return Task.CompletedTask;
+ }
+
+ private bool IsBitpayAPI(HttpContext httpContext, bool bitpayAuth)
+ {
+ if (!httpContext.Request.Path.HasValue)
+ return false;
+
+ // In case of anyone can create an invoice, the storeId can be set explicitly
+ bitpayAuth |= httpContext.Request.Query.ContainsKey("storeid");
+
+ var isJson = (httpContext.Request.ContentType ?? string.Empty).StartsWith("application/json", StringComparison.OrdinalIgnoreCase);
+ var path = httpContext.Request.Path.Value;
+ var method = httpContext.Request.Method;
+ var isCors = method == "OPTIONS";
+
+ if (
+ (isCors || bitpayAuth) &&
+ (path == "/invoices" || path == "/invoices/") &&
+ (isCors || (method == "POST" && isJson)))
+ return true;
+
+ if (
+ (isCors || bitpayAuth) &&
+ (path == "/invoices" || path == "/invoices/") &&
+ (isCors || method == "GET"))
+ return true;
+
+ if (
+ path.StartsWith("/invoices/", StringComparison.OrdinalIgnoreCase) &&
+ (isCors || method == "GET") &&
+ (isCors || isJson || httpContext.Request.Query.ContainsKey("token")))
+ return true;
+
+ if (path.StartsWith("/rates", StringComparison.OrdinalIgnoreCase) &&
+ (isCors || method == "GET"))
+ return true;
+
+ if (
+ path.Equals("/tokens", StringComparison.OrdinalIgnoreCase) &&
+ (isCors || method == "GET" || method == "POST"))
+ return true;
+
+ return false;
+ }
+
+ public override int Order { get; } = 100;
+}
diff --git a/BTCPayServer/Plugins/Bitpay/BitpayExtensions.cs b/BTCPayServer/Plugins/Bitpay/BitpayExtensions.cs
new file mode 100644
index 0000000..57bc30a
--- /dev/null
+++ b/BTCPayServer/Plugins/Bitpay/BitpayExtensions.cs
@@ -0,0 +1,27 @@
+#nullable enable
+using System.Linq;
+using System.Security.Claims;
+using BTCPayServer.Plugins.Bitpay.Security;
+using Microsoft.AspNetCore.Http;
+
+namespace BTCPayServer.Plugins.Bitpay;
+
+public static class BitpayExtensions
+{
+
+ public static string? GetSIN(this ClaimsPrincipal principal)
+ => principal.Claims.Where(c => c.Type == BitpayClaims.SIN).Select(c => c.Value).FirstOrDefault();
+
+ public static bool TryGetBitpayAuth(this HttpContext httpContext, out (string? Signature, string? Id, string? Authorization) result)
+ {
+ httpContext.Request.Headers.TryGetValue("x-signature", out var values);
+ var sig = values.FirstOrDefault();
+ httpContext.Request.Headers.TryGetValue("x-identity", out values);
+ var id = values.FirstOrDefault();
+ httpContext.Request.Headers.TryGetValue("Authorization", out values);
+ var auth = values.FirstOrDefault();
+ var hasBitpayAuth = auth != null || (sig != null && id != null);
+ result = hasBitpayAuth ? (sig, id, auth) : default;
+ return hasBitpayAuth;
+ }
+}
diff --git a/BTCPayServer/Plugins/Bitpay/BitpayFilterAttribute.cs b/BTCPayServer/Plugins/Bitpay/BitpayFilterAttribute.cs
new file mode 100644
index 0000000..9f1f2af
--- /dev/null
+++ b/BTCPayServer/Plugins/Bitpay/BitpayFilterAttribute.cs
@@ -0,0 +1,25 @@
+#nullable enable
+using System.Net.WebSockets;
+using System.Threading.Tasks;
+using BTCPayServer.Plugins.Bitpay.Models;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Filters;
+
+namespace BTCPayServer.Plugins.Bitpay;
+
+public class BitpayFilterAttribute : ActionFilterAttribute
+{
+ public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
+ {
+ var result = await next();
+ if (result.Exception is WebSocketException)
+ {
+ result.ExceptionHandled = true;
+ }
+ else if (result.Exception is BitpayHttpException ex)
+ {
+ result.Result = new JsonResult(new BitpayErrorsModel(ex)) { StatusCode = ex.StatusCode };
+ result.ExceptionHandled = true;
+ }
+ }
+}
diff --git a/BTCPayServer/Plugins/Bitpay/BitpayPlugin.cs b/BTCPayServer/Plugins/Bitpay/BitpayPlugin.cs
new file mode 100644
index 0000000..f487cb5
--- /dev/null
+++ b/BTCPayServer/Plugins/Bitpay/BitpayPlugin.cs
@@ -0,0 +1,30 @@
+#nullable enable
+using BTCPayServer.Abstractions.Constants;
+using BTCPayServer.Abstractions.Models;
+using BTCPayServer.Plugins.Bitpay.Controllers;
+using BTCPayServer.Plugins.Bitpay.Security;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+
+namespace BTCPayServer.Plugins.Bitpay;
+
+public class BitpayPlugin : BaseBTCPayServerPlugin
+{
+ public const string Area = "Bitpay";
+ public override string Identifier => "BTCPayServer.Plugins.Bitpay";
+ public override string Name => "Bitpay";
+ public override string Description => "Add a compatibility layer to the legacy Bitpay API";
+
+ public override void Execute(IServiceCollection services)
+ {
+ services.AddSingleton<MatcherPolicy, BitpayEndpointSelectorPolicy>();
+ services.TryAddSingleton<TokenRepository>();
+ services.AddTransient<BitpayAccessTokenController>();
+ services.AddScoped<IAuthorizationHandler, BitpayAuthorizationHandler>();
+ services.AddAuthentication()
+ .AddScheme<BitpayAuthenticationOptions, BitpayAuthenticationHandler>(AuthenticationSchemes.Bitpay, o => { });
+ services.AddUIExtension("store-category-nav", "/Plugins/Bitpay/Views/NavExtension.cshtml");
+ }
+}
diff --git a/BTCPayServer/Plugins/Bitpay/Controllers/BitpayAccessTokenController.cs b/BTCPayServer/Plugins/Bitpay/Controllers/BitpayAccessTokenController.cs
new file mode 100644
index 0000000..403b224
--- /dev/null
+++ b/BTCPayServer/Plugins/Bitpay/Controllers/BitpayAccessTokenController.cs
@@ -0,0 +1,85 @@
+#nullable enable
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using BTCPayServer.Abstractions.Constants;
+using BTCPayServer.Models;
+using BTCPayServer.Plugins.Bitpay.Models;
+using BTCPayServer.Plugins.Bitpay.Security;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace BTCPayServer.Plugins.Bitpay.Controllers;
+
+[Authorize(AuthenticationSchemes = AuthenticationSchemes.Bitpay)]
+[BitpayEndpointSelectorPolicy.BitpayEndpointMetadata]
+[BitpayFilter]
+public class BitpayAccessTokenController(TokenRepository tokenRepository) : Controller
+{
+
+ [HttpGet]
+ [Route("tokens")]
+ public async Task<GetTokensResponse> Tokens()
+ {
+ var tokens = await tokenRepository.GetTokens(this.User.GetSIN());
+ return new GetTokensResponse(tokens);
+ }
+
+ [HttpPost]
+ [Route("tokens")]
+ [AllowAnonymous]
+ public async Task<DataWrapper<List<PairingCodeResponse>>> Tokens([FromBody] TokenRequest request)
+ {
+ if (request == null)
+ throw new BitpayHttpException(400, "The request body is missing");
+ PairingCodeEntity? pairingEntity;
+ if (string.IsNullOrEmpty(request.PairingCode))
+ {
+ if (string.IsNullOrEmpty(request.Id) || !NBitpayClient.Extensions.BitIdExtensions.ValidateSIN(request.Id))
+ throw new BitpayHttpException(400, "'id' property is required");
+
+ var pairingCode = await tokenRepository.CreatePairingCodeAsync();
+ await tokenRepository.PairWithSINAsync(pairingCode, request.Id);
+ pairingEntity = await tokenRepository.UpdatePairingCode(new PairingCodeEntity()
+ {
+ Id = pairingCode,
+ Label = request.Label
+ });
+ }
+ else
+ {
+ var sin = this.User.GetSIN() ?? request.Id;
+ if (string.IsNullOrEmpty(sin) || !NBitpayClient.Extensions.BitIdExtensions.ValidateSIN(sin))
+ throw new BitpayHttpException(400, "'id' property is required, alternatively, use BitId");
+
+ pairingEntity = await tokenRepository.GetPairingAsync(request.PairingCode);
+ if (pairingEntity == null)
+ throw new BitpayHttpException(404, "The specified pairingCode is not found");
+ pairingEntity.SIN = sin;
+
+ if (string.IsNullOrEmpty(pairingEntity.Label) && !string.IsNullOrEmpty(request.Label))
+ {
+ pairingEntity.Label = request.Label;
+ await tokenRepository.UpdatePairingCode(pairingEntity);
+ }
+
+ var result = await tokenRepository.PairWithSINAsync(request.PairingCode, sin);
+ if (result != PairingResult.Complete && result != PairingResult.Partial)
+ throw new BitpayHttpException(400, $"Error while pairing ({result})");
+ }
+
+ var pairingCodes = new List<PairingCodeResponse>
+ {
+ new()
+ {
+ Policies = new Newtonsoft.Json.Linq.JArray(),
+ PairingCode = pairingEntity.Id,
+ PairingExpiration = pairingEntity.Expiration,
+ DateCreated = pairingEntity.CreatedTime,
+ Facade = "merchant",
+ Token = pairingEntity.TokenValue,
+ Label = pairingEntity.Label
+ }
+ };
+ return DataWrapper.Create(pairingCodes);
+ }
+}
diff --git a/BTCPayServer/Plugins/Bitpay/Controllers/BitpayInvoiceController.cs b/BTCPayServer/Plugins/Bitpay/Controllers/BitpayInvoiceController.cs
new file mode 100644
index 0000000..ab374af
--- /dev/null
+++ b/BTCPayServer/Plugins/Bitpay/Controllers/BitpayInvoiceController.cs
@@ -0,0 +1,234 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using BTCPayServer.Abstractions.Constants;
+using BTCPayServer.Abstractions.Extensions;
+using BTCPayServer.Client;
+using BTCPayServer.Client.Models;
+using BTCPayServer.Controllers;
+using BTCPayServer.Data;
+using BTCPayServer.Filters;
+using BTCPayServer.ModelBinders;
+using BTCPayServer.Models;
+using BTCPayServer.Payments;
+using BTCPayServer.Plugins.Bitpay.Models;
+using BTCPayServer.Security.Greenfield;
+using BTCPayServer.Services.Invoices;
+using BTCPayServer.Services.Rates;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using NBitpayClient;
+using StoreData = BTCPayServer.Data.StoreData;
+
+namespace BTCPayServer.Plugins.Bitpay.Controllers;
+
+[BitpayEndpointSelectorPolicy.BitpayEndpointMetadata]
+[Authorize(Policies.CanCreateInvoice, AuthenticationSchemes = AuthenticationSchemes.Bitpay)]
+[BitpayFilter]
+public class BitpayInvoiceController : Controller
+{
+ private readonly UIInvoiceController _InvoiceController;
+ private readonly Dictionary<PaymentMethodId, IPaymentMethodBitpayAPIExtension> _bitpayExtensions;
+ private readonly CurrencyNameTable _currencyNameTable;
+ private readonly InvoiceRepository _InvoiceRepository;
+
+ public BitpayInvoiceController(UIInvoiceController invoiceController,
+ Dictionary<PaymentMethodId, IPaymentMethodBitpayAPIExtension> bitpayExtensions,
+ CurrencyNameTable currencyNameTable,
+ InvoiceRepository invoiceRepository)
+ {
+ _InvoiceController = invoiceController;
+ _bitpayExtensions = bitpayExtensions;
+ _currencyNameTable = currencyNameTable;
+ _InvoiceRepository = invoiceRepository;
+ }
+
+ [HttpPost]
+ [Route("invoices")]
+ [MediaTypeConstraint("application/json")]
+ public async Task<DataWrapper<InvoiceResponse>> CreateInvoice([FromBody] BitpayCreateInvoiceRequest invoice, CancellationToken cancellationToken)
+ {
+ if (invoice == null)
+ throw new BitpayHttpException(400, "Invalid invoice");
+ return await CreateInvoiceCore(invoice, HttpContext.GetStoreData(), HttpContext.Request.GetAbsoluteRoot(), cancellationToken: cancellationToken);
+ }
+
+ [HttpGet]
+ [Route("invoices/{id}")]
+ public async Task<DataWrapper<InvoiceResponse>> GetInvoice(string id)
+ {
+ var invoice = (await _InvoiceRepository.GetInvoices(new InvoiceQuery()
+ {
+ InvoiceId = new[] { id },
+ StoreId = new[] { HttpContext.GetStoreData().Id }
+ })).FirstOrDefault();
+ if (invoice == null)
+ throw new BitpayHttpException(404, "Object not found");
+ return new DataWrapper<InvoiceResponse>(invoice.EntityToDTO(_bitpayExtensions, Url, _currencyNameTable));
+ }
+
+ [HttpGet]
+ [Route("invoices")]
+ public async Task<IActionResult> GetInvoices(
+ string token,
+ [ModelBinder(typeof(BitpayDateTimeOffsetModelBinder))]
+ DateTimeOffset? dateStart = null,
+ [ModelBinder(typeof(BitpayDateTimeOffsetModelBinder))]
+ DateTimeOffset? dateEnd = null,
+ string orderId = null,
+ string itemCode = null,
+ string status = null,
+ int? limit = null,
+ int? offset = null)
+ {
+ if (User.Identity?.AuthenticationType == Security.BitpayAuthenticationTypes.Anonymous)
+ return Forbid(Security.BitpayAuthenticationTypes.Anonymous);
+ if (dateEnd != null)
+ dateEnd = dateEnd.Value + TimeSpan.FromDays(1); //Should include the end day
+
+ var query = new InvoiceQuery()
+ {
+ Take = limit,
+ Skip = offset,
+ EndDate = dateEnd,
+ StartDate = dateStart,
+ OrderId = orderId == null ? null : new[] { orderId },
+ ItemCode = itemCode == null ? null : new[] { itemCode },
+ Status = status == null ? null : new[] { status },
+ StoreId = new[] { this.HttpContext.GetStoreData().Id }
+ };
+
+ var entities = (await _InvoiceRepository.GetInvoices(query))
+ .Select((o) => o.EntityToDTO(_bitpayExtensions, Url, _currencyNameTable)).ToArray();
+
+ return Json(DataWrapper.Create(entities));
+ }
+
+ internal async Task<DataWrapper<InvoiceResponse>> CreateInvoiceCore(BitpayCreateInvoiceRequest invoice,
+ StoreData store, string serverUrl, List<string> additionalTags = null,
+ CancellationToken cancellationToken = default, Action<InvoiceEntity> entityManipulator = null)
+ {
+ var entity = await CreateInvoiceCoreRaw(invoice, store, serverUrl, additionalTags, cancellationToken, entityManipulator);
+ var resp = entity.EntityToDTO(_bitpayExtensions, Url, _currencyNameTable);
+ return new DataWrapper<InvoiceResponse>(resp) { Facade = "pos/invoice" };
+ }
+
+ internal async Task<InvoiceEntity> CreateInvoiceCoreRaw(BitpayCreateInvoiceRequest invoice, StoreData store, string serverUrl,
+ List<string> additionalTags = null, CancellationToken cancellationToken = default, Action<InvoiceEntity> entityManipulator = null)
+ {
+ var storeBlob = store.GetStoreBlob();
+ var entity = _InvoiceRepository.CreateNewInvoice(store.Id);
+ entity.ExpirationTime = invoice.ExpirationTime is { } v ? v : entity.InvoiceTime + storeBlob.InvoiceExpiration;
+ entity.MonitoringExpiration = entity.ExpirationTime + storeBlob.MonitoringExpiration;
+ if (entity.ExpirationTime - TimeSpan.FromSeconds(30.0) < entity.InvoiceTime)
+ {
+ throw new BitpayHttpException(400, "The expirationTime is set too soon");
+ }
+
+ entity.Metadata.OrderId = invoice.OrderId;
+ entity.Metadata.PosDataLegacy = invoice.PosData;
+ entity.ServerUrl = serverUrl;
+ entity.FullNotifications = invoice.FullNotifications || invoice.ExtendedNotifications;
+ entity.ExtendedNotifications = invoice.ExtendedNotifications;
+ entity.NotificationURLTemplate = invoice.NotificationURL;
+ entity.NotificationEmail = invoice.NotificationEmail;
+ if (additionalTags != null)
+ entity.InternalTags.AddRange(additionalTags);
+ FillBuyerInfo(invoice, entity);
+
+ var price = invoice.Price;
+ entity.Metadata.ItemCode = invoice.ItemCode;
+ entity.Metadata.ItemDesc = invoice.ItemDesc;
+ entity.Metadata.Physical = invoice.Physical;
+ entity.Metadata.TaxIncluded = invoice.TaxIncluded;
+ entity.Currency = invoice.Currency;
+ if (price is { } vv)
+ {
+ entity.Price = vv;
+ entity.Type = InvoiceType.Standard;
+ }
+ else
+ {
+ entity.Price = 0m;
+ entity.Type = InvoiceType.TopUp;
+ }
+
+ entity.StoreSupportUrl = storeBlob.StoreSupportUrl;
+ entity.RedirectURLTemplate = invoice.RedirectURL ?? store.StoreWebsite;
+ entity.RedirectAutomatically =
+ invoice.RedirectAutomatically.GetValueOrDefault(storeBlob.RedirectAutomatically);
+ entity.SpeedPolicy = ParseSpeedPolicy(invoice.TransactionSpeed, store.SpeedPolicy);
+
+ IPaymentFilter excludeFilter = null;
+ if (invoice.PaymentCurrencies?.Any() is true)
+ {
+ invoice.SupportedTransactionCurrencies ??=
+ new Dictionary<string, InvoiceSupportedTransactionCurrency>();
+ foreach (string paymentCurrency in invoice.PaymentCurrencies)
+ {
+ invoice.SupportedTransactionCurrencies.TryAdd(paymentCurrency,
+ new InvoiceSupportedTransactionCurrency() { Enabled = true });
+ }
+ }
+
+ if (invoice.SupportedTransactionCurrencies != null && invoice.SupportedTransactionCurrencies.Count != 0)
+ {
+ var supportedTransactionCurrencies = invoice.SupportedTransactionCurrencies
+ .Where(c => c.Value.Enabled)
+ .Select(c => PaymentMethodId.TryParse(c.Key, out var p) ? p : null)
+ .Where(c => c != null)
+ .ToHashSet();
+ excludeFilter = PaymentFilter.Where(p => !supportedTransactionCurrencies.Contains(p));
+ }
+
+ entity.PaymentTolerance = storeBlob.PaymentTolerance;
+ if (invoice.DefaultPaymentMethod is not null && PaymentMethodId.TryParse(invoice.DefaultPaymentMethod, out var defaultPaymentMethod))
+ {
+ entity.DefaultPaymentMethod = defaultPaymentMethod;
+ }
+
+ return await _InvoiceController.CreateInvoiceCoreRaw(entity, store, excludeFilter, null, cancellationToken, entityManipulator);
+ }
+
+ private void FillBuyerInfo(BitpayCreateInvoiceRequest req, InvoiceEntity invoiceEntity)
+ {
+ var buyerInformation = invoiceEntity.Metadata;
+ buyerInformation.BuyerAddress1 = req.BuyerAddress1;
+ buyerInformation.BuyerAddress2 = req.BuyerAddress2;
+ buyerInformation.BuyerCity = req.BuyerCity;
+ buyerInformation.BuyerCountry = req.BuyerCountry;
+ buyerInformation.BuyerEmail = req.BuyerEmail;
+ buyerInformation.BuyerName = req.BuyerName;
+ buyerInformation.BuyerPhone = req.BuyerPhone;
+ buyerInformation.BuyerState = req.BuyerState;
+ buyerInformation.BuyerZip = req.BuyerZip;
+ var buyer = req.Buyer;
+ if (buyer == null)
+ return;
+ buyerInformation.BuyerAddress1 ??= buyer.Address1;
+ buyerInformation.BuyerAddress2 ??= buyer.Address2;
+ buyerInformation.BuyerCity ??= buyer.City;
+ buyerInformation.BuyerCountry ??= buyer.country;
+ buyerInformation.BuyerEmail ??= buyer.email;
+ buyerInformation.BuyerName ??= buyer.Name;
+ buyerInformation.BuyerPhone ??= buyer.phone;
+ buyerInformation.BuyerState ??= buyer.State;
+ buyerInformation.BuyerZip ??= buyer.zip;
+ }
+
+ private SpeedPolicy ParseSpeedPolicy(string transactionSpeed, SpeedPolicy defaultPolicy)
+ {
+ if (transactionSpeed == null)
+ return defaultPolicy;
+ var mappings = new Dictionary<string, SpeedPolicy>();
+ mappings.Add("low", SpeedPolicy.LowSpeed);
+ mappings.Add("low-medium", SpeedPolicy.LowMediumSpeed);
+ mappings.Add("medium", SpeedPolicy.MediumSpeed);
+ mappings.Add("high", SpeedPolicy.HighSpeed);
+ if (!mappings.TryGetValue(transactionSpeed, out SpeedPolicy policy))
+ policy = defaultPolicy;
+ return policy;
+ }
+}
diff --git a/BTCPayServer/Plugins/Bitpay/Controllers/BitpayRateController.cs b/BTCPayServer/Plugins/Bitpay/Controllers/BitpayRateController.cs
new file mode 100644
index 0000000..25ed6ee
--- /dev/null
+++ b/BTCPayServer/Plugins/Bitpay/Controllers/BitpayRateController.cs
@@ -0,0 +1,193 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using BTCPayServer.Abstractions.Constants;
+using BTCPayServer.Data;
+using BTCPayServer.Models;
+using BTCPayServer.Payments;
+using BTCPayServer.Plugins.Bitpay.Models;
+using BTCPayServer.Rating;
+using BTCPayServer.Security;
+using BTCPayServer.Services.Invoices;
+using BTCPayServer.Services.Rates;
+using BTCPayServer.Services.Stores;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Newtonsoft.Json;
+
+namespace BTCPayServer.Plugins.Bitpay.Controllers;
+
+[Authorize(Policy = ServerPolicies.CanGetRates.Key, AuthenticationSchemes = AuthenticationSchemes.Bitpay)]
+[BitpayFilter]
+public class BitpayRateController : Controller
+{
+ readonly RateFetcher _rateProviderFactory;
+ readonly CurrencyNameTable _currencyNameTable;
+ private readonly DefaultRulesCollection _defaultRules;
+ private readonly PaymentMethodHandlerDictionary _handlers;
+ readonly StoreRepository _storeRepo;
+ private readonly InvoiceRepository _invoiceRepository;
+
+ private StoreData CurrentStore => HttpContext.GetStoreData();
+
+ public BitpayRateController(
+ RateFetcher rateProviderFactory,
+ StoreRepository storeRepo,
+ InvoiceRepository invoiceRepository,
+ CurrencyNameTable currencyNameTable,
+ DefaultRulesCollection defaultRules,
+ PaymentMethodHandlerDictionary handlers)
+ {
+ _rateProviderFactory = rateProviderFactory ?? throw new ArgumentNullException(nameof(rateProviderFactory));
+ _storeRepo = storeRepo;
+ _invoiceRepository = invoiceRepository;
+ _currencyNameTable = currencyNameTable ?? throw new ArgumentNullException(nameof(currencyNameTable));
+ _defaultRules = defaultRules;
+ _handlers = handlers;
+ }
+
+ [Route("rates/{baseCurrency}")]
+ [HttpGet]
+ [BitpayEndpointSelectorPolicy.BitpayEndpointMetadata]
+ public async Task<IActionResult> GetBaseCurrencyRates(string baseCurrency, string cryptoCode = null, CancellationToken cancellationToken = default)
+ {
+ var inv = _invoiceRepository.CreateNewInvoice(CurrentStore.Id);
+ inv.Currency = baseCurrency;
+ var ctx = new InvoiceCreationContext(CurrentStore, CurrentStore.GetStoreBlob(), inv, new Logging.InvoiceLogs(), _handlers, null);
+ ctx.SetLazyActivation(true);
+ await ctx.BeforeFetchingRates();
+ var currencyCodes = ctx
+ .PaymentMethodContexts
+ .SelectMany(c => c.Value.RequiredRates)
+ .Where(c => c.Left.Equals(baseCurrency, StringComparison.OrdinalIgnoreCase))
+ .Select(c => c.Right)
+ .ToHashSet();
+ var currencypairs = BuildCurrencyPairs(currencyCodes, baseCurrency);
+
+ var result = await GetRates2(currencypairs, null, cryptoCode, cancellationToken);
+ var rates = (result as JsonResult)?.Value as Rate[];
+ return rates == null ? result : Json(new DataWrapper<Rate[]>(rates));
+ }
+
+ [HttpGet("rates/{baseCurrency}/{currency}")]
+ [BitpayEndpointSelectorPolicy.BitpayEndpointMetadata]
+ public async Task<IActionResult> GetCurrencyPairRate(string baseCurrency, string currency, string cryptoCode = null,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await GetRates2($"{baseCurrency}_{currency}", null, cryptoCode, cancellationToken);
+ return (result as JsonResult)?.Value is not Rate[] { Length: > 0 } rates
+ ? result
+ : Json(new DataWrapper<Rate>(rates.FirstOrDefault()));
+ }
+
+ [HttpGet("rates")]
+ [BitpayEndpointSelectorPolicy.BitpayEndpointMetadata]
+ public async Task<IActionResult> GetRates(string currencyPairs, string storeId = null, string cryptoCode = null,
+ CancellationToken cancellationToken = default)
+ {
+ var result = await GetRates2(currencyPairs, storeId, cryptoCode, cancellationToken);
+ return (result as JsonResult)?.Value is not Rate[] rates
+ ? result
+ : Json(new DataWrapper<Rate[]>(rates));
+ }
+
+ [AllowAnonymous]
+ [HttpGet("api/rates")]
+ public async Task<IActionResult> GetRates2(string currencyPairs, string storeId, string cryptoCode = null, CancellationToken cancellationToken = default)
+ {
+ var store = CurrentStore ?? await _storeRepo.FindStore(storeId);
+ if (store == null)
+ {
+ var err = Json(new BitpayErrorsModel { Error = "Store not found" });
+ err.StatusCode = 404;
+ return err;
+ }
+
+ if (currencyPairs == null)
+ {
+ var blob = store.GetStoreBlob();
+ currencyPairs = blob.GetDefaultCurrencyPairString();
+ if (string.IsNullOrEmpty(currencyPairs) && !string.IsNullOrWhiteSpace(cryptoCode))
+ {
+ currencyPairs = $"{blob.DefaultCurrency}_{cryptoCode}".ToUpperInvariant();
+ }
+
+ if (string.IsNullOrEmpty(currencyPairs))
+ {
+ var result = Json(new BitpayErrorsModel()
+ {
+ Error =
+ "You need to setup the default currency pairs in 'Store Settings / Rates' or specify 'currencyPairs' query parameter (eg. BTC_USD,LTC_CAD)."
+ });
+ result.StatusCode = 400;
+ return result;
+ }
+ }
+
+ var rules = store.GetStoreBlob().GetRateRules(_defaultRules);
+ var pairs = new HashSet<CurrencyPair>();
+ foreach (var currency in currencyPairs.Split(','))
+ {
+ if (!CurrencyPair.TryParse(currency, out var pair))
+ {
+ var result = Json(new BitpayErrorsModel() { Error = $"Currency pair {currency} incorrectly formatted" });
+ result.StatusCode = 400;
+ return result;
+ }
+
+ pairs.Add(pair);
+ }
+
+ var fetching = _rateProviderFactory.FetchRates(pairs, rules, new StoreIdRateContext(store.Id), cancellationToken);
+ await Task.WhenAll(fetching.Select(f => f.Value).ToArray());
+ return Json(pairs
+ .Select(r => (Pair: r, Value: fetching[r].GetAwaiter().GetResult().BidAsk?.Bid))
+ .Where(r => r.Value.HasValue)
+ .Select(r =>
+ new Rate
+ {
+ CryptoCode = r.Pair.Left,
+ Code = r.Pair.Right,
+ CurrencyPair = r.Pair.ToString(),
+ Name = _currencyNameTable.GetCurrencyData(r.Pair.Right, true).Name,
+ Value = r.Value.Value
+ }).Where(n => n.Name != null).ToArray());
+ }
+
+ private static string BuildCurrencyPairs(IEnumerable<string> currencyCodes, string baseCrypto)
+ {
+ var currencyPairsBuilder = new StringBuilder();
+ bool first = true;
+ foreach (var currencyCode in currencyCodes)
+ {
+ if (!first)
+ currencyPairsBuilder.Append(',');
+ first = false;
+ currencyPairsBuilder.Append(CultureInfo.InvariantCulture, $"{baseCrypto}_{currencyCode}");
+ }
+
+ return currencyPairsBuilder.ToString();
+ }
+
+ public class Rate
+ {
+ [JsonProperty(PropertyName = "name")]
+ public string Name { get; set; }
+
+ [JsonProperty(PropertyName = "cryptoCode")]
+ public string CryptoCode { get; set; }
+
+ [JsonProperty(PropertyName = "currencyPair")]
+ public string CurrencyPair { get; set; }
+
+ [JsonProperty(PropertyName = "code")]
+ public string Code { get; set; }
+
+ [JsonProperty(PropertyName = "rate")]
+ public decimal Value { get; set; }
+ }
+}
diff --git a/BTCPayServer/Plugins/Bitpay/Controllers/UIStoresTokenController.cs b/BTCPayServer/Plugins/Bitpay/Controllers/UIStoresTokenController.cs
new file mode 100644
index 0000000..4c66227
--- /dev/null
+++ b/BTCPayServer/Plugins/Bitpay/Controllers/UIStoresTokenController.cs
@@ -0,0 +1,278 @@
+#nullable enable
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using BTCPayServer.Abstractions.Constants;
+using BTCPayServer.Abstractions.Models;
+using BTCPayServer.Client;
+using BTCPayServer.Controllers;
+using BTCPayServer.Data;
+using BTCPayServer.Models;
+using BTCPayServer.Plugins.Bitpay.Security;
+using BTCPayServer.Plugins.Bitpay.Views;
+using BTCPayServer.Services.Invoices;
+using BTCPayServer.Services.Stores;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Rendering;
+using Microsoft.Extensions.Localization;
+using NBitcoin;
+using NBitcoin.DataEncoders;
+
+namespace BTCPayServer.Plugins.Bitpay.Controllers;
+
+[Route("stores")]
+[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+[Area(BitpayPlugin.Area)]
+public class UIStoresTokenController(
+ TokenRepository tokenRepository,
+ BitpayAccessTokenController tokenController,
+ IStringLocalizer stringLocalizer,
+ StoreRepository storeRepository,
+ UserManager<ApplicationUser> userManager,
+ IHtmlHelper html,
+ PaymentMethodHandlerDictionary handlers) : Controller
+{
+ public IStringLocalizer StringLocalizer { get; } = stringLocalizer;
+ public StoreData CurrentStore => HttpContext.GetStoreData() ?? throw new InvalidOperationException("Store not found");
+ private string? GetUserId() => userManager.GetUserId(User);
+
+ [TempData]
+ public bool StoreNotConfigured { get; set; }
+ public string? GeneratedPairingCode { get; set; }
+ [HttpGet("{storeId}/tokens")]
+ [Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+ public async Task<IActionResult> ListTokens()
+ {
+ var model = new TokensViewModel();
+ var tokens = await tokenRepository.GetTokensByStoreIdAsync(CurrentStore.Id);
+ model.StoreNotConfigured = StoreNotConfigured;
+ model.Tokens = tokens.Select(t => new TokenViewModel()
+ {
+ Label = t.Label,
+ SIN = t.SIN,
+ Id = t.Value
+ }).ToArray();
+
+ model.ApiKey = (await tokenRepository.GetLegacyAPIKeys(CurrentStore.Id)).FirstOrDefault();
+ model.EncodedApiKey = model.ApiKey == null ? "*API Key*" : Encoders.Base64.EncodeData(Encoders.ASCII.DecodeData(model.ApiKey));
+ return View(model);
+ }
+
+ [HttpGet("{storeId}/tokens/{tokenId}/revoke")]
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+ public async Task<IActionResult> RevokeToken(string tokenId)
+ {
+ var token = await tokenRepository.GetToken(tokenId);
+ if (token == null || token.StoreId != CurrentStore.Id)
+ return NotFound();
+ return View("Confirm", new ConfirmModel(StringLocalizer["Revoke the token"], $"The access token with the label <strong>{html.Encode(token.Label)}</strong> will be revoked. Do you wish to continue?", "Revoke"));
+ }
+
+ [HttpPost("{storeId}/tokens/{tokenId}/revoke")]
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+ public async Task<IActionResult> RevokeTokenConfirm(string tokenId)
+ {
+ var token = await tokenRepository.GetToken(tokenId);
+ if (token == null ||
+ token.StoreId != CurrentStore.Id ||
+ !await tokenRepository.DeleteToken(tokenId))
+ TempData[WellKnownTempData.ErrorMessage] = "Failure to revoke this token.";
+ else
+ TempData[WellKnownTempData.SuccessMessage] = "Token revoked";
+ return RedirectToAction(nameof(ListTokens), new { storeId = token?.StoreId });
+ }
+
+ [HttpGet("{storeId}/tokens/{tokenId}")]
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+ public async Task<IActionResult> ShowToken(string tokenId)
+ {
+ var token = await tokenRepository.GetToken(tokenId);
+ if (token == null || token.StoreId != CurrentStore.Id)
+ return NotFound();
+ return View(token);
+ }
+
+ [HttpGet("{storeId}/tokens/create")]
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+ public IActionResult CreateToken(string storeId)
+ {
+ var model = new CreateTokenViewModel();
+ ViewBag.HidePublicKey = false;
+ ViewBag.ShowStores = false;
+ model.StoreId = storeId;
+ return View(model);
+ }
+
+ [HttpPost("{storeId}/tokens/create")]
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+ public async Task<IActionResult> CreateToken(string storeId, CreateTokenViewModel model)
+ {
+ if (!ModelState.IsValid)
+ {
+ return View(nameof(CreateToken), model);
+ }
+ model.Label ??= string.Empty;
+ var userId = GetUserId();
+ if (userId == null)
+ return Challenge(AuthenticationSchemes.Cookie);
+ var store = model.StoreId switch
+ {
+ null => CurrentStore,
+ _ => await storeRepository.FindStore(storeId, userId)
+ };
+ if (store == null)
+ return Challenge(AuthenticationSchemes.Cookie);
+ var tokenRequest = new TokenRequest()
+ {
+ Label = model.Label,
+ Id = model.PublicKey == null ? null : NBitpayClient.Extensions.BitIdExtensions.GetBitIDSIN(new PubKey(model.PublicKey).Compress())
+ };
+
+ string? pairingCode;
+ if (model.PublicKey == null)
+ {
+ tokenRequest.PairingCode = await tokenRepository.CreatePairingCodeAsync();
+ await tokenRepository.UpdatePairingCode(new PairingCodeEntity()
+ {
+ Id = tokenRequest.PairingCode,
+ Label = model.Label,
+ });
+ await tokenRepository.PairWithStoreAsync(tokenRequest.PairingCode, store.Id);
+ pairingCode = tokenRequest.PairingCode;
+ }
+ else
+ {
+ pairingCode = (await tokenController.Tokens(tokenRequest)).Data[0].PairingCode;
+ }
+
+ GeneratedPairingCode = pairingCode;
+ return RedirectToAction(nameof(RequestPairing), new
+ {
+ pairingCode,
+ selectedStore = storeId
+ });
+ }
+
+ [HttpGet("/api-tokens")]
+ [AllowAnonymous]
+ public async Task<IActionResult> CreateToken()
+ {
+ var userId = GetUserId();
+ if (string.IsNullOrWhiteSpace(userId))
+ return Challenge(AuthenticationSchemes.Cookie);
+ var model = new CreateTokenViewModel();
+ ViewBag.HidePublicKey = true;
+ ViewBag.ShowStores = true;
+ var stores = (await storeRepository.GetStoresByUserId(userId)).Where(data => data.HasPermission(userId, Policies.CanModifyStoreSettings)).ToArray();
+
+ model.Stores = new SelectList(stores, nameof(CurrentStore.Id), nameof(CurrentStore.StoreName));
+ if (!model.Stores.Any())
+ {
+ TempData[WellKnownTempData.ErrorMessage] = "You need to be owner of at least one store before pairing";
+ return RedirectToAction(nameof(UIHomeController.Index), "UIHome");
+ }
+ return View(model);
+ }
+
+ [HttpPost("/api-tokens")]
+ [AllowAnonymous]
+ public Task<IActionResult> CreateToken2(CreateTokenViewModel model)
+ {
+ return CreateToken(model.StoreId, model);
+ }
+
+ [HttpPost("{storeId}/tokens/apikey")]
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+ public async Task<IActionResult> GenerateAPIKey(string storeId, string command = "")
+ {
+ var store = HttpContext.GetStoreData();
+ if (store == null)
+ return NotFound();
+ if (command == "revoke")
+ {
+ await tokenRepository.RevokeLegacyAPIKeys(CurrentStore.Id);
+ TempData[WellKnownTempData.SuccessMessage] = "API Key revoked";
+ }
+ else
+ {
+ await tokenRepository.GenerateLegacyAPIKey(CurrentStore.Id);
+ TempData[WellKnownTempData.SuccessMessage] = "API Key re-generated";
+ }
+
+ return RedirectToAction(nameof(ListTokens), new
+ {
+ storeId
+ });
+ }
+
+ [HttpGet("/api-access-request")]
+ [AllowAnonymous]
+ public async Task<IActionResult> RequestPairing(string pairingCode, string? selectedStore = null)
+ {
+ var userId = GetUserId();
+ if (userId == null)
+ return Challenge(AuthenticationSchemes.Cookie);
+
+ if (selectedStore != null)
+ {
+ var store = await storeRepository.FindStore(selectedStore, userId);
+ if (store == null)
+ return NotFound();
+ HttpContext.SetStoreData(store);
+ }
+
+ var pairing = await tokenRepository.GetPairingAsync(pairingCode);
+ if (pairing == null)
+ {
+ TempData[WellKnownTempData.ErrorMessage] = "Unknown pairing code";
+ return RedirectToAction(nameof(UIHomeController.Index), "UIHome");
+ }
+
+ var stores = (await storeRepository.GetStoresByUserId(userId)).Where(data => data.HasPermission(userId, Policies.CanModifyStoreSettings)).ToArray();
+ return View(new PairingModel
+ {
+ Id = pairing.Id,
+ Label = pairing.Label,
+ SIN = pairing.SIN ?? "Server-Initiated Pairing",
+ StoreId = selectedStore ?? stores.FirstOrDefault()?.Id,
+ Stores = stores.Select(s => new PairingModel.StoreViewModel
+ {
+ Id = s.Id,
+ Name = string.IsNullOrEmpty(s.StoreName) ? s.Id : s.StoreName
+ }).ToArray()
+ });
+ }
+
+ [HttpPost("/api-access-request")]
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+ public async Task<IActionResult> Pair(string pairingCode, string storeId)
+ {
+ var store = CurrentStore;
+ var pairing = await tokenRepository.GetPairingAsync(pairingCode);
+ if (pairing == null || store.Id != storeId)
+ return NotFound();
+
+ var pairingResult = await tokenRepository.PairWithStoreAsync(pairingCode, store.Id);
+ if (pairingResult == PairingResult.Complete || pairingResult == PairingResult.Partial)
+ {
+ var excludeFilter = store.GetStoreBlob().GetExcludedPaymentMethods();
+ StoreNotConfigured = store.GetPaymentMethodConfigs(handlers).All(p => excludeFilter.Match(p.Key));
+ TempData[WellKnownTempData.SuccessMessage] = "Pairing is successful";
+ if (pairingResult == PairingResult.Partial)
+ TempData[WellKnownTempData.SuccessMessage] = $"Server initiated pairing code: {pairingCode}";
+ return RedirectToAction(nameof(ListTokens), new
+ {
+ storeId = store.Id, pairingCode
+ });
+ }
+
+ TempData[WellKnownTempData.ErrorMessage] = $"Pairing failed: {pairingResult}";
+ return RedirectToAction(nameof(ListTokens), new
+ {
+ storeId = store.Id
+ });
+ }
+}
diff --git a/BTCPayServer/Plugins/Bitpay/Models/BitpayCreateInvoiceRequest.cs b/BTCPayServer/Plugins/Bitpay/Models/BitpayCreateInvoiceRequest.cs
new file mode 100644
index 0000000..c060061
--- /dev/null
+++ b/BTCPayServer/Plugins/Bitpay/Models/BitpayCreateInvoiceRequest.cs
@@ -0,0 +1,88 @@
+using System;
+using System.Collections.Generic;
+using BTCPayServer.Models;
+using NBitpayClient;
+using Newtonsoft.Json;
+
+namespace BTCPayServer.Plugins.Bitpay.Models
+{
+ public class BitpayCreateInvoiceRequest
+ {
+ [JsonProperty(PropertyName = "buyer")]
+ public Buyer Buyer { get; set; }
+ [JsonProperty(PropertyName = "buyerEmail", DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public string BuyerEmail { get; set; }
+ [JsonProperty(PropertyName = "buyerCountry", DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public string BuyerCountry { get; set; }
+ [JsonProperty(PropertyName = "buyerZip", DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public string BuyerZip { get; set; }
+ [JsonProperty(PropertyName = "buyerState", DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public string BuyerState { get; set; }
+ [JsonProperty(PropertyName = "buyerCity", DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public string BuyerCity { get; set; }
+ [JsonProperty(PropertyName = "buyerAddress2", DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public string BuyerAddress2 { get; set; }
+ [JsonProperty(PropertyName = "buyerAddress1", DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public string BuyerAddress1 { get; set; }
+ [JsonProperty(PropertyName = "buyerName", DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public string BuyerName { get; set; }
+ [JsonProperty(PropertyName = "physical", DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public bool Physical { get; set; }
+ [JsonProperty(PropertyName = "redirectURL", DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public string RedirectURL { get; set; }
+ [JsonProperty(PropertyName = "notificationURL", DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public string NotificationURL { get; set; }
+ [JsonProperty(PropertyName = "extendedNotifications", DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public bool ExtendedNotifications { get; set; }
+ [JsonProperty(PropertyName = "fullNotifications", DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public bool FullNotifications { get; set; }
+ [JsonProperty(PropertyName = "transactionSpeed", DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public string TransactionSpeed { get; set; }
+ [JsonProperty(PropertyName = "buyerPhone", DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public string BuyerPhone { get; set; }
+ [JsonProperty(PropertyName = "posData", DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public string PosData { get; set; }
+ [JsonProperty(PropertyName = "itemCode", DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public string ItemCode { get; set; }
+ [JsonProperty(PropertyName = "itemDesc", DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public string ItemDesc { get; set; }
+ [JsonProperty(PropertyName = "orderId", DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public string OrderId { get; set; }
+ [JsonProperty(PropertyName = "currency", DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public string Currency { get; set; }
+ [JsonProperty(PropertyName = "price", DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public decimal? Price { get; set; }
+ [JsonProperty(PropertyName = "defaultPaymentMethod", DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public string DefaultPaymentMethod { get; set; }
+ [JsonProperty(PropertyName = "notificationEmail", DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public string NotificationEmail { get; set; }
+ [JsonConverter(typeof(DateTimeJsonConverter))]
+ [JsonProperty(PropertyName = "expirationTime", DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public DateTimeOffset? ExpirationTime { get; set; }
+ [JsonProperty(PropertyName = "status", DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public string Status { get; set; }
+ [JsonProperty(PropertyName = "minerFees", DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public Dictionary<string, MinerFeeInfo> MinerFees { get; set; }
+ [JsonProperty(PropertyName = "supportedTransactionCurrencies", DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public Dictionary<string, InvoiceSupportedTransactionCurrency> SupportedTransactionCurrencies { get; set; }
+ [JsonProperty(PropertyName = "exchangeRates", DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public Dictionary<string, Dictionary<string, decimal>> ExchangeRates { get; set; }
+ [JsonProperty(PropertyName = "refundable", DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public bool Refundable { get; set; }
+ [JsonProperty(PropertyName = "taxIncluded", DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public decimal? TaxIncluded { get; set; }
+ [JsonProperty(PropertyName = "nonce", DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public long Nonce { get; set; }
+ [JsonProperty(PropertyName = "guid", DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public string Guid { get; set; }
+ [JsonProperty(PropertyName = "token", DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public string Token { get; set; }
+
+ [JsonProperty(PropertyName = "redirectAutomatically", DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public bool? RedirectAutomatically { get; set; }
+
+ //Bitpay compatibility: create invoice in btcpay uses this instead of supportedTransactionCurrencies
+ [JsonProperty(PropertyName = "paymentCurrencies", DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public IEnumerable<string> PaymentCurrencies { get; set; }
+ }
+}
diff --git a/BTCPayServer/Plugins/Bitpay/Models/BitpayErrorsModel.cs b/BTCPayServer/Plugins/Bitpay/Models/BitpayErrorsModel.cs
new file mode 100644
index 0000000..0028ab5
--- /dev/null
+++ b/BTCPayServer/Plugins/Bitpay/Models/BitpayErrorsModel.cs
@@ -0,0 +1,36 @@
+using Newtonsoft.Json;
+
+namespace BTCPayServer.Plugins.Bitpay.Models
+{
+ public class BitpayErrorsModel
+ {
+ public BitpayErrorsModel()
+ {
+
+ }
+ public BitpayErrorsModel(BitpayHttpException ex)
+ {
+ Error = ex.Message;
+ }
+
+ [JsonProperty("errors", DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public BitpayErrorModel[] Errors
+ {
+ get; set;
+ }
+ [JsonProperty("error", DefaultValueHandling = DefaultValueHandling.Ignore)]
+ public string Error
+ {
+ get; set;
+ }
+ }
+
+ public class BitpayErrorModel
+ {
+ [JsonProperty("error")]
+ public string Error
+ {
+ get; set;
+ }
+ }
+}
diff --git a/BTCPayServer/Plugins/Bitpay/Models/GetTokensResponse.cs b/BTCPayServer/Plugins/Bitpay/Models/GetTokensResponse.cs
new file mode 100644
index 0000000..b80c42a
--- /dev/null
+++ b/BTCPayServer/Plugins/Bitpay/Models/GetTokensResponse.cs
@@ -0,0 +1,50 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using BTCPayServer.Plugins.Bitpay.Security;
+using Microsoft.AspNetCore.Mvc;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+
+namespace BTCPayServer.Plugins.Bitpay.Models
+{
+ //{"data":[{"pos":"FfZ6WCa8TunAvPCpQZXkdBsoH4Yo18FyPaJ5X5qjrVVY"},{"pos/invoice":"H1pwwh2tMeSCri9rh5VvHWEHokGdf2EGtghfZkUEbeZv"},{"merchant":"89zEBr9orAc6wgybAABp8ioGcjYeFrUaZgMzjxNuqYty"},{"merchant/invoice":"8e7ijDxGfJsWXWgJuKXjjNgxnX1xpsBM8cTZCFnU7ehj"}]}
+ public class GetTokensResponse : IActionResult
+ {
+ readonly BitTokenEntity[] _Tokens;
+ public GetTokensResponse(BitTokenEntity[] tokens)
+ {
+ ArgumentNullException.ThrowIfNull(tokens);
+ this._Tokens = tokens;
+ }
+
+ [JsonProperty(PropertyName = "data")]
+ //{"pos":"FfZ6WCa8TunAvPCpQZXkdBsoH4Yo18FyPaJ5X5qjrVVY"}
+ public JArray Data
+ {
+ get; set;
+ }
+
+ public async Task ExecuteResultAsync(ActionContext context)
+ {
+ JObject jobj = new JObject();
+ JArray jarray = new JArray();
+ jobj.Add("data", jarray);
+ var token = _Tokens.FirstOrDefault();
+ if (token != null)
+ {
+ JObject item = new JObject();
+ jarray.Add(item);
+ JProperty jProp = new JProperty("merchant");
+ item.Add(jProp);
+ jProp.Value = token.Value;
+ }
+ context.HttpContext.Response.Headers.Add("Content-Type", new Microsoft.Extensions.Primitives.StringValues("application/json"));
+ var str = JsonConvert.SerializeObject(jobj);
+ await using var writer = new StreamWriter(context.HttpContext.Response.Body, new UTF8Encoding(false), 1024 * 10, true);
+ await writer.WriteAsync(str);
+ }
+ }
+}
diff --git a/BTCPayServer/Plugins/Bitpay/Security/BitToken.cs b/BTCPayServer/Plugins/Bitpay/Security/BitToken.cs
new file mode 100644
index 0000000..51aaa4b
--- /dev/null
+++ b/BTCPayServer/Plugins/Bitpay/Security/BitToken.cs
@@ -0,0 +1,42 @@
+using System;
+using NBitpayClient;
+
+namespace BTCPayServer.Plugins.Bitpay.Security
+{
+ public class BitTokenEntity
+ {
+ public string Value
+ {
+ get; set;
+ }
+ public string StoreId
+ {
+ get; set;
+ }
+ public string Label
+ {
+ get; set;
+ }
+ public DateTimeOffset PairingTime
+ {
+ get; set;
+ }
+ public string SIN
+ {
+ get;
+ set;
+ }
+
+ public BitTokenEntity Clone(Facade facade)
+ {
+ return new BitTokenEntity()
+ {
+ Label = Label,
+ StoreId = StoreId,
+ PairingTime = PairingTime,
+ SIN = SIN,
+ Value = Value
+ };
+ }
+ }
+}
diff --git a/BTCPayServer/Plugins/Bitpay/Security/BitpayAuthenticationHandler.cs b/BTCPayServer/Plugins/Bitpay/Security/BitpayAuthenticationHandler.cs
new file mode 100644
index 0000000..0927c98
--- /dev/null
+++ b/BTCPayServer/Plugins/Bitpay/Security/BitpayAuthenticationHandler.cs
@@ -0,0 +1,125 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Security.Claims;
+using System.Text;
+using System.Text.Encodings.Web;
+using System.Threading.Tasks;
+using BTCPayServer.Plugins.Bitpay.Models;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.Extensions;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using NBitcoin;
+using NBitcoin.DataEncoders;
+using NBitpayClient.Extensions;
+using Newtonsoft.Json;
+
+
+namespace BTCPayServer.Plugins.Bitpay.Security
+{
+ public class BitpayAuthenticationHandler(
+ TokenRepository tokenRepository,
+ IOptionsMonitor<BitpayAuthenticationOptions> options,
+ ILoggerFactory logger,
+ UrlEncoder encoder)
+ : AuthenticationHandler<BitpayAuthenticationOptions>(options, logger, encoder)
+ {
+ const string BitpayAuthErrorKey = nameof(BitpayAuthErrorKey);
+ protected override Task HandleChallengeAsync(AuthenticationProperties properties)
+ {
+ var reason = Context.Items[BitpayAuthErrorKey]?.ToString() ?? "Authentication required";
+ Response.StatusCode = StatusCodes.Status401Unauthorized;
+ Response.ContentType = "application/json";
+ return Response.WriteAsync(JsonConvert.SerializeObject(new BitpayErrorsModel()
+ {
+ Errors = [new() { Error = reason }],
+ Error = reason
+ }));
+ }
+
+ protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
+ {
+ Context.Request.HttpContext.TryGetBitpayAuth(out var bitpayAuth);
+ if (!string.IsNullOrEmpty(bitpayAuth.Signature) && !string.IsNullOrEmpty(bitpayAuth.Id))
+ {
+ var sin = await CheckBitId(Context.Request.HttpContext, bitpayAuth.Signature, bitpayAuth.Id);
+ if (sin == null)
+ return Fail("BitId authentication failed");
+ return Success(BitpayClaims.SIN, sin, BitpayAuthenticationTypes.SinAuthentication);
+ }
+ else if (!string.IsNullOrEmpty(bitpayAuth.Authorization))
+ {
+ var storeId = await GetStoreIdFromAuth(bitpayAuth.Authorization);
+ if (storeId == null)
+ return Fail("ApiKey authentication failed");
+ return Success(BitpayClaims.ApiKeyStoreId, storeId, BitpayAuthenticationTypes.ApiKeyAuthentication);
+ }
+ else
+ {
+ return Success(null, null, BitpayAuthenticationTypes.Anonymous);
+ }
+ }
+
+ private AuthenticateResult Fail(string reason)
+ {
+ Context.Items[BitpayAuthErrorKey] = reason;
+ return AuthenticateResult.Fail(reason);
+ }
+
+ private AuthenticateResult Success(string claimType, string claimValue, string authenticationType)
+ {
+ List<Claim> claims = new List<Claim>();
+ if (claimType != null)
+ claims.Add(new Claim(claimType, claimValue));
+ return AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(new ClaimsIdentity(claims, authenticationType)), authenticationType));
+ }
+
+ private async Task<string> CheckBitId(HttpContext httpContext, string sig, string id)
+ {
+ httpContext.Request.EnableBuffering();
+ string body = string.Empty;
+ if (httpContext.Request.ContentLength != 0 && httpContext.Request.Body != null)
+ {
+ using (StreamReader reader = new StreamReader(httpContext.Request.Body, Encoding.UTF8, true, 1024, true))
+ {
+ body = await reader.ReadToEndAsync();
+ }
+ httpContext.Request.Body.Position = 0;
+ }
+
+ var url = httpContext.Request.GetEncodedUrl();
+ try
+ {
+ var key = new PubKey(id);
+ if (BitIdExtensions.CheckBitIDSignature(key, sig, url, body))
+ {
+ return key.GetBitIDSIN();
+ }
+ }
+ catch { }
+ return null;
+ }
+
+ private async Task<string> GetStoreIdFromAuth(string auth)
+ {
+ var splitted = auth.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ if (splitted.Length != 2 || !splitted[0].Equals("Basic", StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ string apiKey = null;
+ try
+ {
+ apiKey = Encoders.ASCII.EncodeData(Encoders.Base64.DecodeData(splitted[1]));
+ }
+ catch
+ {
+ return null;
+ }
+ return await tokenRepository.GetStoreIdFromAPIKey(apiKey);
+ }
+ }
+}
diff --git a/BTCPayServer/Plugins/Bitpay/Security/BitpayAuthenticationOptions.cs b/BTCPayServer/Plugins/Bitpay/Security/BitpayAuthenticationOptions.cs
new file mode 100644
index 0000000..d294215
--- /dev/null
+++ b/BTCPayServer/Plugins/Bitpay/Security/BitpayAuthenticationOptions.cs
@@ -0,0 +1,8 @@
+using Microsoft.AspNetCore.Authentication;
+
+namespace BTCPayServer.Plugins.Bitpay.Security
+{
+ public class BitpayAuthenticationOptions : AuthenticationSchemeOptions
+ {
+ }
+}
diff --git a/BTCPayServer/Plugins/Bitpay/Security/BitpayAuthenticationTypes.cs b/BTCPayServer/Plugins/Bitpay/Security/BitpayAuthenticationTypes.cs
new file mode 100644
index 0000000..03588c4
--- /dev/null
+++ b/BTCPayServer/Plugins/Bitpay/Security/BitpayAuthenticationTypes.cs
@@ -0,0 +1,9 @@
+namespace BTCPayServer.Plugins.Bitpay.Security
+{
+ public class BitpayAuthenticationTypes
+ {
+ public const string ApiKeyAuthentication = "Bitpay.APIKey";
+ public const string SinAuthentication = "Bitpay.SIN";
+ public const string Anonymous = "Bitpay.Anonymous";
+ }
+}
diff --git a/BTCPayServer/Plugins/Bitpay/Security/BitpayAuthorizationHandler.cs b/BTCPayServer/Plugins/Bitpay/Security/BitpayAuthorizationHandler.cs
new file mode 100644
index 0000000..1a4994b
--- /dev/null
+++ b/BTCPayServer/Plugins/Bitpay/Security/BitpayAuthorizationHandler.cs
@@ -0,0 +1,68 @@
+using System.Linq;
+using System.Threading.Tasks;
+using BTCPayServer.Client;
+using BTCPayServer.Data;
+using BTCPayServer.Security;
+using BTCPayServer.Services.Stores;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+
+namespace BTCPayServer.Plugins.Bitpay.Security
+{
+ public class BitpayAuthorizationHandler : AuthorizationHandler<PolicyRequirement>
+ {
+ private readonly HttpContext _HttpContext;
+ private readonly StoreRepository _storeRepository;
+ private readonly TokenRepository _tokenRepository;
+
+ public BitpayAuthorizationHandler(IHttpContextAccessor httpContextAccessor,
+ StoreRepository storeRepository,
+ TokenRepository tokenRepository)
+ {
+ _HttpContext = httpContextAccessor.HttpContext;
+ _storeRepository = storeRepository;
+ _tokenRepository = tokenRepository;
+ }
+
+ protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, PolicyRequirement requirement)
+ {
+ string storeId = null;
+ if (context.User.Identity.AuthenticationType == BitpayAuthenticationTypes.ApiKeyAuthentication)
+ {
+ storeId = context.User.Claims.Where(c => c.Type == BitpayClaims.ApiKeyStoreId).Select(c => c.Value).First();
+ }
+ else if (context.User.Identity.AuthenticationType == BitpayAuthenticationTypes.SinAuthentication)
+ {
+ var sin = context.User.Claims.Where(c => c.Type == BitpayClaims.SIN).Select(c => c.Value).First();
+ var bitToken = (await _tokenRepository.GetTokens(sin)).FirstOrDefault();
+ storeId = bitToken?.StoreId;
+ }
+ else if (context.User.Identity.AuthenticationType == BitpayAuthenticationTypes.Anonymous)
+ {
+ storeId = _HttpContext.GetImplicitStoreId();
+ }
+ if (storeId == null)
+ return;
+ var store = await _storeRepository.FindStore(storeId);
+ if (store == null)
+ return;
+ var isAnonymous = context.User.Identity.AuthenticationType == BitpayAuthenticationTypes.Anonymous;
+ var anyoneCanInvoice = store.GetStoreBlob().AnyoneCanInvoice;
+ switch (requirement.Policy)
+ {
+ case Policies.CanCreateInvoice:
+ if (!isAnonymous || (isAnonymous && anyoneCanInvoice))
+ {
+ context.Succeed(requirement);
+ _HttpContext.SetStoreData(store);
+ return;
+ }
+ break;
+ case ServerPolicies.CanGetRates.Key:
+ context.Succeed(requirement);
+ _HttpContext.SetStoreData(store);
+ return;
+ }
+ }
+ }
+}
diff --git a/BTCPayServer/Plugins/Bitpay/Security/BitpayClaims.cs b/BTCPayServer/Plugins/Bitpay/Security/BitpayClaims.cs
new file mode 100644
index 0000000..cc4f966
--- /dev/null
+++ b/BTCPayServer/Plugins/Bitpay/Security/BitpayClaims.cs
@@ -0,0 +1,8 @@
+namespace BTCPayServer.Plugins.Bitpay.Security
+{
+ public class BitpayClaims
+ {
+ public const string SIN = "Bitpay.SIN";
+ public const string ApiKeyStoreId = "Bitpay.ApiKeyStoreId";
+ }
+}
diff --git a/BTCPayServer/Plugins/Bitpay/Security/PairingCodeEntity.cs b/BTCPayServer/Plugins/Bitpay/Security/PairingCodeEntity.cs
new file mode 100644
index 0000000..14bbd3e
--- /dev/null
+++ b/BTCPayServer/Plugins/Bitpay/Security/PairingCodeEntity.cs
@@ -0,0 +1,43 @@
+using System;
+
+namespace BTCPayServer.Plugins.Bitpay.Security
+{
+ public class PairingCodeEntity
+ {
+ public string Id
+ {
+ get;
+ set;
+ }
+ public string Label
+ {
+ get;
+ set;
+ }
+ public string SIN
+ {
+ get;
+ set;
+ }
+ public DateTimeOffset CreatedTime
+ {
+ get;
+ set;
+ }
+ public DateTimeOffset Expiration
+ {
+ get;
+ set;
+ }
+ public string TokenValue
+ {
+ get;
+ set;
+ }
+
+ public bool IsExpired()
+ {
+ return DateTimeOffset.UtcNow > Expiration;
+ }
+ }
+}
diff --git a/BTCPayServer/Plugins/Bitpay/Security/TokenRepository.cs b/BTCPayServer/Plugins/Bitpay/Security/TokenRepository.cs
new file mode 100644
index 0000000..c1379b0
--- /dev/null
+++ b/BTCPayServer/Plugins/Bitpay/Security/TokenRepository.cs
@@ -0,0 +1,231 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using BTCPayServer.Data;
+using Microsoft.EntityFrameworkCore;
+using NBitcoin;
+using NBitcoin.DataEncoders;
+
+namespace BTCPayServer.Plugins.Bitpay.Security
+{
+ public enum PairingResult
+ {
+ Partial,
+ Complete,
+ ReusedKey,
+ Expired
+ }
+
+ public class TokenRepository
+ {
+ readonly ApplicationDbContextFactory _Factory;
+ public TokenRepository(ApplicationDbContextFactory dbFactory)
+ {
+ ArgumentNullException.ThrowIfNull(dbFactory);
+ _Factory = dbFactory;
+ }
+
+ public async Task<BitTokenEntity[]> GetTokens(string sin)
+ {
+ if (sin == null)
+ return Array.Empty<BitTokenEntity>();
+ using var ctx = _Factory.CreateContext();
+ return (await ctx.PairedSINData.Where(p => p.SIN == sin)
+ .ToArrayAsync())
+ .Select(p => CreateTokenEntity(p))
+ .ToArray();
+ }
+
+ public async Task<String> GetStoreIdFromAPIKey(string apiKey)
+ {
+ using var ctx = _Factory.CreateContext();
+ return await ctx.ApiKeys.Where(o => o.Id == apiKey).Select(o => o.StoreId).FirstOrDefaultAsync();
+ }
+
+ public async Task GenerateLegacyAPIKey(string storeId)
+ {
+ // It is legacy support and Bitpay generate string of unknown format, trying to replicate them
+ // as good as possible. The string below got generated for me.
+ var chars = "ERo0vkBMOYhyU0ZHvirCplbLDIGWPdi1ok77VnW7QdE";
+ var generated = new char[chars.Length];
+ for (int i = 0; i < generated.Length; i++)
+ {
+ generated[i] = chars[(int)(RandomUtils.GetUInt32() % generated.Length)];
+ }
+
+ using var ctx = _Factory.CreateContext();
+ var existing = await ctx.ApiKeys.Where(o => o.StoreId == storeId && o.Type == APIKeyType.Legacy).ToListAsync();
+ if (existing.Any())
+ {
+ ctx.ApiKeys.RemoveRange(existing);
+ }
+ ctx.ApiKeys.Add(new APIKeyData() { Id = new string(generated), StoreId = storeId });
+ await ctx.SaveChangesAsync().ConfigureAwait(false);
+ }
+
+ public async Task RevokeLegacyAPIKeys(string storeId)
+ {
+ var keys = await GetLegacyAPIKeys(storeId);
+ if (!keys.Any())
+ {
+ return;
+ }
+
+ using var ctx = _Factory.CreateContext();
+ ctx.ApiKeys.RemoveRange(keys.Select(s => new APIKeyData() { Id = s }));
+ await ctx.SaveChangesAsync();
+ }
+
+ public async Task<string[]> GetLegacyAPIKeys(string storeId)
+ {
+ using var ctx = _Factory.CreateContext();
+ return await ctx.ApiKeys.Where(o => o.StoreId == storeId && o.Type == APIKeyType.Legacy).Select(c => c.Id).ToArrayAsync();
+ }
+
+ private BitTokenEntity CreateTokenEntity(PairedSINData data)
+ {
+ return new BitTokenEntity()
+ {
+ Label = data.Label,
+ Value = data.Id,
+ SIN = data.SIN,
+ PairingTime = data.PairingTime,
+ StoreId = data.StoreDataId
+ };
+ }
+
+ public async Task<string> CreatePairingCodeAsync()
+ {
+ string pairingCodeId = null;
+ while (true)
+ {
+ pairingCodeId = Encoders.Base58.EncodeData(RandomUtils.GetBytes(6));
+ if (pairingCodeId.Length == 7) // woocommerce plugin check for exactly 7 digits
+ break;
+ }
+ using (var ctx = _Factory.CreateContext())
+ {
+ var now = DateTime.UtcNow;
+ var expiration = DateTime.UtcNow + TimeSpan.FromMinutes(15);
+ ctx.PairingCodes.Add(new PairingCodeData()
+ {
+ Id = pairingCodeId,
+ DateCreated = now,
+ Expiration = expiration,
+ TokenValue = Encoders.Base58.EncodeData(RandomUtils.GetBytes(32))
+ });
+ await ctx.SaveChangesAsync();
+ }
+ return pairingCodeId;
+ }
+
+ public async Task<PairingCodeEntity> UpdatePairingCode(PairingCodeEntity pairingCodeEntity)
+ {
+ using var ctx = _Factory.CreateContext();
+ var pairingCode = await ctx.PairingCodes.FindAsync(pairingCodeEntity.Id);
+ pairingCode.Label = pairingCodeEntity.Label;
+ await ctx.SaveChangesAsync();
+ return CreatePairingCodeEntity(pairingCode);
+ }
+
+ public async Task<PairingResult> PairWithStoreAsync(string pairingCodeId, string storeId)
+ {
+ using var ctx = _Factory.CreateContext();
+ var pairingCode = await ctx.PairingCodes.FindAsync(pairingCodeId);
+ if (pairingCode == null || pairingCode.Expiration < DateTimeOffset.UtcNow)
+ return PairingResult.Expired;
+ pairingCode.StoreDataId = storeId;
+ var result = await ActivateIfComplete(ctx, pairingCode);
+ await ctx.SaveChangesAsync();
+ return result;
+ }
+
+ public async Task<PairingResult> PairWithSINAsync(string pairingCodeId, string sin)
+ {
+ using var ctx = _Factory.CreateContext();
+ var pairingCode = await ctx.PairingCodes.FindAsync(pairingCodeId);
+ if (pairingCode == null || pairingCode.Expiration < DateTimeOffset.UtcNow)
+ return PairingResult.Expired;
+ pairingCode.SIN = sin;
+ var result = await ActivateIfComplete(ctx, pairingCode);
+ await ctx.SaveChangesAsync();
+ return result;
+ }
+
+
+ private async Task<PairingResult> ActivateIfComplete(ApplicationDbContext ctx, PairingCodeData pairingCode)
+ {
+ if (!string.IsNullOrEmpty(pairingCode.SIN) && !string.IsNullOrEmpty(pairingCode.StoreDataId))
+ {
+ ctx.PairingCodes.Remove(pairingCode);
+
+ // Can have concurrency issues... but no harm can be done
+ var alreadyUsed = await ctx.PairedSINData.Where(p => p.SIN == pairingCode.SIN && p.StoreDataId != pairingCode.StoreDataId).AnyAsync();
+ if (alreadyUsed)
+ return PairingResult.ReusedKey;
+ await ctx.PairedSINData.AddAsync(new PairedSINData()
+ {
+ Id = pairingCode.TokenValue,
+ PairingTime = DateTime.UtcNow,
+ Label = pairingCode.Label,
+ StoreDataId = pairingCode.StoreDataId,
+ SIN = pairingCode.SIN
+ });
+ return PairingResult.Complete;
+ }
+ return PairingResult.Partial;
+ }
+
+
+ public async Task<BitTokenEntity[]> GetTokensByStoreIdAsync(string storeId)
+ {
+ using var ctx = _Factory.CreateContext();
+ return (await ctx.PairedSINData.Where(p => p.StoreDataId == storeId).ToListAsync())
+ .Select(c => CreateTokenEntity(c))
+ .ToArray();
+ }
+
+ public async Task<PairingCodeEntity> GetPairingAsync(string pairingCode)
+ {
+ using var ctx = _Factory.CreateContext();
+ return CreatePairingCodeEntity(await ctx.PairingCodes.FindAsync(pairingCode));
+ }
+
+ private PairingCodeEntity CreatePairingCodeEntity(PairingCodeData data)
+ {
+ if (data == null)
+ return null;
+ return new PairingCodeEntity()
+ {
+ Id = data.Id,
+ Label = data.Label,
+ Expiration = data.Expiration,
+ CreatedTime = data.DateCreated,
+ TokenValue = data.TokenValue,
+ SIN = data.SIN
+ };
+ }
+
+
+ public async Task<bool> DeleteToken(string tokenId)
+ {
+ using var ctx = _Factory.CreateContext();
+ var token = await ctx.PairedSINData.FindAsync(tokenId);
+ if (token == null)
+ return false;
+ ctx.PairedSINData.Remove(token);
+ await ctx.SaveChangesAsync();
+ return true;
+ }
+
+ public async Task<BitTokenEntity> GetToken(string tokenId)
+ {
+ using var ctx = _Factory.CreateContext();
+ var token = await ctx.PairedSINData.FindAsync(tokenId);
+ if (token == null)
+ return null;
+ return CreateTokenEntity(token);
+ }
+
+ }
+}
diff --git a/BTCPayServer/Plugins/Bitpay/Views/CreateToken.cshtml b/BTCPayServer/Plugins/Bitpay/Views/CreateToken.cshtml
new file mode 100644
index 0000000..91a871e
--- /dev/null
+++ b/BTCPayServer/Plugins/Bitpay/Views/CreateToken.cshtml
@@ -0,0 +1,65 @@
+@model CreateTokenViewModel
+@{
+ var store = Context.GetStoreData();
+ ViewData.SetLayoutModel(new(nameof(StoreNavPages.Tokens), StringLocalizer["Create New Token"]));
+ ViewBag.HidePublicKey ??= false;
+ ViewBag.ShowStores ??= false;
+ Layout = "_Layout";
+}
+
+<form method="post">
+ <div class="sticky-header">
+ @if (store != null)
+ {
+ <nav aria-label="breadcrumb">
+ <ol class="breadcrumb">
+ <li class="breadcrumb-item">
+ <a asp-action="ListTokens" asp-route-storeId="@store.Id" text-translate="true">Access Tokens</a>
+ </li>
+ <li class="breadcrumb-item active" aria-current="page">@ViewData["Title"]</li>
+ </ol>
+ <h2>@ViewData["Title"]</h2>
+ </nav>
+ }
+ else
+ {
+ <h2>@ViewData["Title"]</h2>
+ }
+ <input id="page-primary" type="submit" value="Request Pairing" class="btn btn-primary" />
+ </div>
+ <partial name="_StatusMessage" />
+ <div class="row">
+ <div class="col-xxl-constrain col-xl-8">
+ <div class="form-group">
+ <label asp-for="Label" class="form-label"></label>
+ @if (ViewBag.HidePublicKey)
+ {
+ <small class="text-muted" text-translate="true">optional</small>
+ }
+ <input asp-for="Label" class="form-control" />
+ <span asp-validation-for="Label" class="text-danger"></span>
+ </div>
+ @if (!ViewBag.HidePublicKey)
+ {
+ <div class="form-group">
+ <label asp-for="PublicKey" class="form-label"></label>
+ <input asp-for="PublicKey" class="form-control" />
+ <span asp-validation-for="PublicKey" class="text-danger"></span>
+ <div class="form-text" text-translate="true">Keep empty for server-initiated pairing.</div>
+ </div>
+ }
+ @if (ViewBag.ShowStores)
+ {
+ <div class="form-group">
+ <label asp-for="Stores" class="form-label"></label>
+ <select asp-for="StoreId" asp-items="Model.Stores" class="form-select"></select>
+ <span asp-validation-for="StoreId" class="text-danger"></span>
+ </div>
+ }
+ else
+ {
+ <input type="hidden" asp-for="StoreId" />
+ }
+ </div>
+ </div>
+</form>
diff --git a/BTCPayServer/Plugins/Bitpay/Views/ListTokens.cshtml b/BTCPayServer/Plugins/Bitpay/Views/ListTokens.cshtml
new file mode 100644
index 0000000..afdf9ec
--- /dev/null
+++ b/BTCPayServer/Plugins/Bitpay/Views/ListTokens.cshtml
@@ -0,0 +1,105 @@
+@using BTCPayServer.Client
+@using Microsoft.AspNetCore.Mvc.TagHelpers
+@model TokensViewModel
+@{
+ Layout = "_Layout";
+ ViewData.SetLayoutModel(new LayoutModel(nameof(StoreNavPages.Tokens), StringLocalizer["Access Tokens"]).SetCategory(WellKnownCategories.Store));
+}
+
+@if (Model.StoreNotConfigured)
+{
+ <div class="alert alert-warning alert-dismissible mb-5" role="alert">
+ <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="@StringLocalizer["Close"]">
+ <vc:icon symbol="close" />
+ </button>
+ <span text-translate="true">Warning: No wallet has been linked to your BTCPay Server Store.</span><br/>
+ See <a href="https://docs.btcpayserver.org/WalletSetup/" target="_blank" class="alert-link" rel="noreferrer noopener">this link</a> for more information on how to connect your store and wallet.
+ </div>
+}
+<div class="sticky-header">
+ <h2 class="my-1">
+ <span text-translate="true">Greenfield API Keys</span>
+ <a href="/docs" target="_blank" rel="noreferrer noopener" title="@StringLocalizer["More information..."]">
+ <vc:icon symbol="info"/>
+ </a>
+ </h2>
+</div>
+
+<partial name="_StatusMessage" />
+<div class="row">
+ <div class="col-xxl-constrain col-xl-8">
+ <p>
+ <span text-translate="true">To generate Greenfield API keys, please</span>
+ <a asp-controller="UIManage" asp-action="APIKeys" text-translate="true">click here</a>.
+ </p>
+
+ <div class="d-flex align-items-center justify-content-between mt-5 mb-3">
+ <h3 class="mb-0">@ViewData["Title"]</h3>
+ <a id="CreateNewToken" asp-action="CreateToken" class="btn btn-primary" role="button" asp-route-storeId="@Context.GetRouteValue("storeId")" permission="@Policies.CanModifyStoreSettings" text-translate="true">
+ Create Token
+ </a>
+ </div>
+ <p>
+ <span text-translate="true">Authorize a public key to access Bitpay compatible Invoice API.</span>
+ <a href="https://support.bitpay.com/hc/en-us/articles/115003001183-How-do-I-pair-my-client-and-create-a-token-" target="_blank" rel="noreferrer noopener" title="@StringLocalizer["More information..."]">
+ <vc:icon symbol="info" />
+ </a>
+ </p>
+
+ @if (Model.Tokens.Any())
+ {
+ <div class="table-responsive-md">
+ <table class="table table-hover">
+ <thead>
+ <tr>
+ <th text-translate="true">Label</th>
+ <th class="text-end" permission="@Policies.CanModifyStoreSettings" text-translate="true">Actions</th>
+ </tr>
+ </thead>
+ <tbody>
+ @foreach (var token in Model.Tokens)
+ {
+ <tr>
+ <td>@token.Label</td>
+ <td class="text-end" permission="@Policies.CanModifyStoreSettings">
+ <a asp-action="ShowToken" asp-route-storeId="@Context.GetRouteValue("storeId")" asp-route-tokenId="@token.Id" text-translate="true">See information</a> -
+ <a asp-action="RevokeToken" asp-route-storeId="@Context.GetRouteValue("storeId")" asp-route-tokenId="@token.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-description="The access token with the label <strong>@Html.Encode(token.Label)</strong> will be revoked." data-confirm-input="REVOKE" text-translate="true">Revoke</a>
+ </td>
+ </tr>
+ }
+ </tbody>
+ </table>
+ </div>
+ }
+ else
+ {
+ <p class="text-secondary mt-3" text-translate="true">
+ No access tokens yet.
+ </p>
+ }
+
+ <h3 class="mt-5 mb-3" text-translate="true">Legacy API Keys</h3>
+ <p text-translate="true">Alternatively, you can use the invoice API by including the following HTTP Header in your requests:</p>
+ <p><code>Authorization: Basic @Model.EncodedApiKey</code></p>
+
+ <form method="post" asp-action="GenerateAPIKey" asp-route-storeId="@Context.GetRouteValue("storeId")" permission="@Policies.CanModifyStoreSettings">
+ <div class="form-group">
+ <label asp-for="ApiKey" class="form-label"></label>
+ <div class="d-flex">
+ <input asp-for="ApiKey" readonly class="form-control"/>
+ @if (string.IsNullOrEmpty(Model.ApiKey))
+ {
+ <button class="btn btn-primary ms-3" type="submit" text-translate="true">Generate</button>
+ }
+ else
+ {
+ <button class="btn btn-danger ms-3" type="submit" name="command" value="revoke" text-translate="true">Revoke</button>
+ <button class="btn btn-primary ms-3" type="submit" text-translate="true">Regenerate</button>
+ }
+ </div>
+ </div>
+ </form>
+ </div>
+</div>
+
+<partial name="_Confirm" model="@(new ConfirmModel(StringLocalizer["Revoke access token"], StringLocalizer["The access token will be revoked. Do you wish to continue?"], StringLocalizer["Revoke"]))" permission="@Policies.CanModifyStoreSettings" />
diff --git a/BTCPayServer/Plugins/Bitpay/Views/NavExtension.cshtml b/BTCPayServer/Plugins/Bitpay/Views/NavExtension.cshtml
new file mode 100644
index 0000000..8c45058
--- /dev/null
+++ b/BTCPayServer/Plugins/Bitpay/Views/NavExtension.cshtml
@@ -0,0 +1,5 @@
+@using BTCPayServer.Client
+@using BTCPayServer.Plugins.Bitpay
+<li class="nav-item nav-item-sub" permission="@Policies.CanViewStoreSettings">
+ <a layout-menu-item="@nameof(StoreNavPages.Tokens)" asp-area="@BitpayPlugin.Area" asp-controller="UIStoresToken" asp-action="ListTokens" asp-route-storeId="@Model.Store.Id" text-translate="true">Access Tokens</a>
+</li>
diff --git a/BTCPayServer/Plugins/Bitpay/Views/PairingModel.cs b/BTCPayServer/Plugins/Bitpay/Views/PairingModel.cs
new file mode 100644
index 0000000..a15ffbf
--- /dev/null
+++ b/BTCPayServer/Plugins/Bitpay/Views/PairingModel.cs
@@ -0,0 +1,43 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace BTCPayServer.Plugins.Bitpay.Views
+{
+ public class PairingModel
+ {
+ public class StoreViewModel
+ {
+ public string Name
+ {
+ get; set;
+ }
+ public string Id
+ {
+ get; set;
+ }
+ }
+ public string Id
+ {
+ get; set;
+ }
+ public string Label
+ {
+ get; set;
+ }
+ public string SIN
+ {
+ get; set;
+ }
+ public StoreViewModel[] Stores
+ {
+ get;
+ set;
+ }
+
+ [Display(Name = "Pair to")]
+ [Required]
+ public string StoreId
+ {
+ get; set;
+ }
+ }
+}
diff --git a/BTCPayServer/Plugins/Bitpay/Views/RequestPairing.cshtml b/BTCPayServer/Plugins/Bitpay/Views/RequestPairing.cshtml
new file mode 100644
index 0000000..597c85d
--- /dev/null
+++ b/BTCPayServer/Plugins/Bitpay/Views/RequestPairing.cshtml
@@ -0,0 +1,68 @@
+@using BTCPayServer.Client
+@model PairingModel
+@{
+ var store = Context.GetStoreData();
+ Layout = store is null ? "_LayoutWizard" : "_Layout";
+ ViewData.SetLayoutModel(new(nameof(StoreNavPages.Tokens), StringLocalizer["Pairing Permission"]));
+}
+
+@if (store is null)
+{
+ @section Navbar {
+ <button type="button" class="cancel" onclick="history.back()">
+ <vc:icon symbol="cross" />
+ </button>
+ }
+}
+<form asp-action="Pair" method="post" permissioned="@Policies.CanModifyStoreSettings">
+ <div class="sticky-header">
+ @if (store != null)
+ {
+ <nav aria-label="breadcrumb">
+ <ol class="breadcrumb">
+ <li class="breadcrumb-item">
+ <a asp-action="ListTokens" asp-route-storeId="@store.Id" text-translate="true">Access Tokens</a>
+ </li>
+ <li class="breadcrumb-item">
+ <a asp-action="CreateToken" asp-route-storeId="@store.Id" text-translate="true">Create Token</a>
+ </li>
+ <li class="breadcrumb-item active" aria-current="page">@ViewData["Title"]</li>
+ </ol>
+ <h2>@ViewData["Title"]</h2>
+ </nav>
+ }
+ else
+ {
+ <h2>@ViewData["Title"]</h2>
+ }
+ <button id="page-primary" type="submit" class="btn btn-primary mt-3" title="@StringLocalizer["Approve this pairing demand"]" text-translate="true">Approve</button>
+ </div>
+ <partial name="_StatusMessage" />
+ <div class="row">
+ <div class="col-sm-8 col-md-6 @(store is null ? "mx-auto" : "")">
+ <table class="table table-hover">
+ <tr>
+ <th text-translate="true">Label</th>
+ <td class="text-end">@Model.Label</td>
+ </tr>
+ <tr>
+ <th text-translate="true">SIN</th>
+ <td class="text-end">@Model.SIN</td>
+ </tr>
+ </table>
+ @if (store is null)
+ {
+ <div class="form-group">
+ <label asp-for="StoreId" class="form-label" text-translate="true">Pair To Store</label>
+ <select asp-for="StoreId" asp-items="@(new SelectList(Model.Stores, "Id", "Name"))" class="form-select"></select>
+ <span asp-validation-for="StoreId" class="text-danger"></span>
+ </div>
+ }
+ else
+ {
+ <input asp-for="StoreId" type="hidden" />
+ }
+ <input type="hidden" name="pairingCode" value="@Model.Id"/>
+ </div>
+ </div>
+</form>
diff --git a/BTCPayServer/Plugins/Bitpay/Views/ShowToken.cshtml b/BTCPayServer/Plugins/Bitpay/Views/ShowToken.cshtml
new file mode 100644
index 0000000..effc150
--- /dev/null
+++ b/BTCPayServer/Plugins/Bitpay/Views/ShowToken.cshtml
@@ -0,0 +1,26 @@
+@model BTCPayServer.Plugins.Bitpay.Security.BitTokenEntity
+@{
+ ViewData.SetLayoutModel(new(nameof(StoreNavPages.Tokens), StringLocalizer["Access Tokens"]));
+ Layout = "_Layout";
+}
+<h2 class="mb-2 mb-lg-3">@ViewData["Title"]</h2>
+<partial name="_StatusMessage" />
+<div class="row">
+ <div class="col-md-8">
+ <h5 text-translate="true">Token Information</h5>
+ <table class="table table-hover">
+ <tr>
+ <th text-translate="true">Label</th>
+ <td>@Model.Label</td>
+ </tr>
+ <tr>
+ <th text-translate="true">SIN</th>
+ <td>@Model.SIN</td>
+ </tr>
+ <tr>
+ <th text-translate="true">Token</th>
+ <td>@Model.Value</td>
+ </tr>
+ </table>
+ </div>
+</div>
diff --git a/BTCPayServer/Plugins/Bitpay/Views/TokensViewModel.cs b/BTCPayServer/Plugins/Bitpay/Views/TokensViewModel.cs
new file mode 100644
index 0000000..3a4cae5
--- /dev/null
+++ b/BTCPayServer/Plugins/Bitpay/Views/TokensViewModel.cs
@@ -0,0 +1,61 @@
+using System.ComponentModel.DataAnnotations;
+using BTCPayServer.Validation;
+using Microsoft.AspNetCore.Mvc.Rendering;
+
+namespace BTCPayServer.Plugins.Bitpay.Views
+{
+ public class CreateTokenViewModel
+ {
+ [Display(Name = "Public Key")]
+ [PubKeyValidatorAttribute]
+ public string PublicKey
+ {
+ get; set;
+ }
+
+ [Display(Name = "Label")]
+ public string Label
+ {
+ get; set;
+ }
+
+ [Required]
+ public string StoreId
+ {
+ get; set;
+ }
+
+ [Display(Name = "Store")]
+ public SelectList Stores
+ {
+ get; set;
+ }
+ }
+ public class TokenViewModel
+ {
+ public string Id
+ {
+ get; set;
+ }
+ public string Label
+ {
+ get; set;
+ }
+ public string SIN
+ {
+ get; set;
+ }
+ }
+ public class TokensViewModel
+ {
+ public TokenViewModel[] Tokens
+ {
+ get; set;
+ }
+
+ [Display(Name = "API Key")]
+ public string ApiKey { get; set; }
+ public string EncodedApiKey { get; set; }
+ public bool StoreNotConfigured { get; set; }
+ }
+}
diff --git a/BTCPayServer/Plugins/Bitpay/Views/_ViewImports.cshtml b/BTCPayServer/Plugins/Bitpay/Views/_ViewImports.cshtml
new file mode 100644
index 0000000..b57e079
--- /dev/null
+++ b/BTCPayServer/Plugins/Bitpay/Views/_ViewImports.cshtml
@@ -0,0 +1,4 @@
+@using BTCPayServer.Abstractions.Extensions
+@using BTCPayServer.Views.Stores
+@using BTCPayServer.Plugins.Bitpay.Views
+@using BTCPayServer.Models.StoreViewModels
diff --git a/BTCPayServer/Plugins/Bitpay/Views/_ViewStart.cshtml b/BTCPayServer/Plugins/Bitpay/Views/_ViewStart.cshtml
new file mode 100644
index 0000000..f1d4cfa
--- /dev/null
+++ b/BTCPayServer/Plugins/Bitpay/Views/_ViewStart.cshtml
@@ -0,0 +1,7 @@
+@using BTCPayServer.Abstractions.Extensions
+@using BTCPayServer.Views
+@using BTCPayServer.Views.Stores
+
+@{
+ ViewData.SetActiveCategory(typeof(StoreNavPages));
+}
diff --git a/BTCPayServer/Security/AuthenticationExtensions.cs b/BTCPayServer/Security/AuthenticationExtensions.cs
deleted file mode 100644
index 0fcfc6d..0000000
--- a/BTCPayServer/Security/AuthenticationExtensions.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-using BTCPayServer.Abstractions.Constants;
-using BTCPayServer.Security.Bitpay;
-using Microsoft.AspNetCore.Authentication;
-
-namespace BTCPayServer.Security
-{
- public static class AuthenticationExtensions
- {
- public static AuthenticationBuilder AddBitpayAuthentication(this AuthenticationBuilder builder)
- {
- builder.AddScheme<BitpayAuthenticationOptions, BitpayAuthenticationHandler>(AuthenticationSchemes.Bitpay, o => { });
- return builder;
- }
- }
-}
diff --git a/BTCPayServer/Security/Bitpay/BitToken.cs b/BTCPayServer/Security/Bitpay/BitToken.cs
deleted file mode 100644
index aa2c1dd..0000000
--- a/BTCPayServer/Security/Bitpay/BitToken.cs
+++ /dev/null
@@ -1,42 +0,0 @@
-using System;
-using NBitpayClient;
-
-namespace BTCPayServer.Security.Bitpay
-{
- public class BitTokenEntity
- {
- public string Value
- {
- get; set;
- }
- public string StoreId
- {
- get; set;
- }
- public string Label
- {
- get; set;
- }
- public DateTimeOffset PairingTime
- {
- get; set;
- }
- public string SIN
- {
- get;
- set;
- }
-
- public BitTokenEntity Clone(Facade facade)
- {
- return new BitTokenEntity()
- {
- Label = Label,
- StoreId = StoreId,
- PairingTime = PairingTime,
- SIN = SIN,
- Value = Value
- };
- }
- }
-}
diff --git a/BTCPayServer/Security/Bitpay/BitpayAuthenticationHandler.cs b/BTCPayServer/Security/Bitpay/BitpayAuthenticationHandler.cs
deleted file mode 100644
index c430e8b..0000000
--- a/BTCPayServer/Security/Bitpay/BitpayAuthenticationHandler.cs
+++ /dev/null
@@ -1,112 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Security.Claims;
-using System.Text;
-using System.Text.Encodings.Web;
-using System.Threading.Tasks;
-using BTCPayServer.Services.Stores;
-using Microsoft.AspNetCore.Authentication;
-using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.Http.Extensions;
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Options;
-using NBitcoin;
-using NBitcoin.DataEncoders;
-using NBitpayClient.Extensions;
-
-
-namespace BTCPayServer.Security.Bitpay
-{
- public class BitpayAuthenticationHandler : AuthenticationHandler<BitpayAuthenticationOptions>
- {
- readonly StoreRepository _StoreRepository;
- readonly TokenRepository _TokenRepository;
- public BitpayAuthenticationHandler(
- TokenRepository tokenRepository,
- StoreRepository storeRepository,
- IOptionsMonitor<BitpayAuthenticationOptions> options, ILoggerFactory logger, UrlEncoder encoder) : base(options, logger, encoder)
- {
- _TokenRepository = tokenRepository;
- _StoreRepository = storeRepository;
- }
-
- protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
- {
- if (!Context.Request.HttpContext.TryGetBitpayAuth(out var bitpayAuth))
- return AuthenticateResult.NoResult();
- if (!string.IsNullOrEmpty(bitpayAuth.Signature) && !string.IsNullOrEmpty(bitpayAuth.Id))
- {
- var sin = await CheckBitId(Context.Request.HttpContext, bitpayAuth.Signature, bitpayAuth.Id);
- if (sin == null)
- return AuthenticateResult.Fail("BitId authentication failed");
- return Success(BitpayClaims.SIN, sin, BitpayAuthenticationTypes.SinAuthentication);
- }
- else if (!string.IsNullOrEmpty(bitpayAuth.Authorization))
- {
- var storeId = await GetStoreIdFromAuth(Context.Request.HttpContext, bitpayAuth.Authorization);
- if (storeId == null)
- return AuthenticateResult.Fail("ApiKey authentication failed");
- return Success(BitpayClaims.ApiKeyStoreId, storeId, BitpayAuthenticationTypes.ApiKeyAuthentication);
- }
- else
- {
- return Success(null, null, BitpayAuthenticationTypes.Anonymous);
- }
- }
-
- private AuthenticateResult Success(string claimType, string claimValue, string authenticationType)
- {
- List<Claim> claims = new List<Claim>();
- if (claimType != null)
- claims.Add(new Claim(claimType, claimValue));
- return AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(new ClaimsIdentity(claims, authenticationType)), authenticationType));
- }
-
- private async Task<string> CheckBitId(HttpContext httpContext, string sig, string id)
- {
- httpContext.Request.EnableBuffering();
- string body = string.Empty;
- if (httpContext.Request.ContentLength != 0 && httpContext.Request.Body != null)
- {
- using (StreamReader reader = new StreamReader(httpContext.Request.Body, Encoding.UTF8, true, 1024, true))
- {
- body = await reader.ReadToEndAsync();
- }
- httpContext.Request.Body.Position = 0;
- }
-
- var url = httpContext.Request.GetEncodedUrl();
- try
- {
- var key = new PubKey(id);
- if (BitIdExtensions.CheckBitIDSignature(key, sig, url, body))
- {
- return key.GetBitIDSIN();
- }
- }
- catch { }
- return null;
- }
-
- private async Task<string> GetStoreIdFromAuth(HttpContext httpContext, string auth)
- {
- var splitted = auth.Split(' ', StringSplitOptions.RemoveEmptyEntries);
- if (splitted.Length != 2 || !splitted[0].Equals("Basic", StringComparison.OrdinalIgnoreCase))
- {
- return null;
- }
-
- string apiKey = null;
- try
- {
- apiKey = Encoders.ASCII.EncodeData(Encoders.Base64.DecodeData(splitted[1]));
- }
- catch
- {
- return null;
- }
- return await _TokenRepository.GetStoreIdFromAPIKey(apiKey);
- }
- }
-}
diff --git a/BTCPayServer/Security/Bitpay/BitpayAuthenticationOptions.cs b/BTCPayServer/Security/Bitpay/BitpayAuthenticationOptions.cs
deleted file mode 100644
index 68d2c6c..0000000
--- a/BTCPayServer/Security/Bitpay/BitpayAuthenticationOptions.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-using Microsoft.AspNetCore.Authentication;
-
-namespace BTCPayServer.Security.Bitpay
-{
- public class BitpayAuthenticationOptions : AuthenticationSchemeOptions
- {
- }
-}
diff --git a/BTCPayServer/Security/Bitpay/BitpayAuthenticationTypes.cs b/BTCPayServer/Security/Bitpay/BitpayAuthenticationTypes.cs
deleted file mode 100644
index 13c55ac..0000000
--- a/BTCPayServer/Security/Bitpay/BitpayAuthenticationTypes.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-namespace BTCPayServer.Security.Bitpay
-{
- public class BitpayAuthenticationTypes
- {
- public const string ApiKeyAuthentication = "Bitpay.APIKey";
- public const string SinAuthentication = "Bitpay.SIN";
- public const string Anonymous = "Bitpay.Anonymous";
- }
-}
diff --git a/BTCPayServer/Security/Bitpay/BitpayAuthorizationHandler.cs b/BTCPayServer/Security/Bitpay/BitpayAuthorizationHandler.cs
deleted file mode 100644
index 1123484..0000000
--- a/BTCPayServer/Security/Bitpay/BitpayAuthorizationHandler.cs
+++ /dev/null
@@ -1,66 +0,0 @@
-using System.Linq;
-using System.Threading.Tasks;
-using BTCPayServer.Client;
-using BTCPayServer.Data;
-using BTCPayServer.Services.Stores;
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Http;
-
-namespace BTCPayServer.Security.Bitpay
-{
- public class BitpayAuthorizationHandler : AuthorizationHandler<PolicyRequirement>
- {
- private readonly HttpContext _HttpContext;
- private readonly StoreRepository _storeRepository;
- private readonly TokenRepository _tokenRepository;
-
- public BitpayAuthorizationHandler(IHttpContextAccessor httpContextAccessor,
- StoreRepository storeRepository,
- TokenRepository tokenRepository)
- {
- _HttpContext = httpContextAccessor.HttpContext;
- _storeRepository = storeRepository;
- _tokenRepository = tokenRepository;
- }
- protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, PolicyRequirement requirement)
- {
- string storeId = null;
- if (context.User.Identity.AuthenticationType == BitpayAuthenticationTypes.ApiKeyAuthentication)
- {
- storeId = context.User.Claims.Where(c => c.Type == BitpayClaims.ApiKeyStoreId).Select(c => c.Value).First();
- }
- else if (context.User.Identity.AuthenticationType == BitpayAuthenticationTypes.SinAuthentication)
- {
- var sin = context.User.Claims.Where(c => c.Type == BitpayClaims.SIN).Select(c => c.Value).First();
- var bitToken = (await _tokenRepository.GetTokens(sin)).FirstOrDefault();
- storeId = bitToken?.StoreId;
- }
- else if (context.User.Identity.AuthenticationType == BitpayAuthenticationTypes.Anonymous)
- {
- storeId = _HttpContext.GetImplicitStoreId();
- }
- if (storeId == null)
- return;
- var store = await _storeRepository.FindStore(storeId);
- if (store == null)
- return;
- var isAnonymous = context.User.Identity.AuthenticationType == BitpayAuthenticationTypes.Anonymous;
- var anyoneCanInvoice = store.GetStoreBlob().AnyoneCanInvoice;
- switch (requirement.Policy)
- {
- case Policies.CanCreateInvoice:
- if (!isAnonymous || (isAnonymous && anyoneCanInvoice))
- {
- context.Succeed(requirement);
- _HttpContext.SetStoreData(store);
- return;
- }
- break;
- case ServerPolicies.CanGetRates.Key:
- context.Succeed(requirement);
- _HttpContext.SetStoreData(store);
- return;
- }
- }
- }
-}
diff --git a/BTCPayServer/Security/Bitpay/BitpayClaims.cs b/BTCPayServer/Security/Bitpay/BitpayClaims.cs
deleted file mode 100644
index fe817ad..0000000
--- a/BTCPayServer/Security/Bitpay/BitpayClaims.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-namespace BTCPayServer.Security.Bitpay
-{
- public class BitpayClaims
- {
- public const string SIN = "Bitpay.SIN";
- public const string ApiKeyStoreId = "Bitpay.ApiKeyStoreId";
- }
-}
diff --git a/BTCPayServer/Security/Bitpay/PairingCodeEntity.cs b/BTCPayServer/Security/Bitpay/PairingCodeEntity.cs
deleted file mode 100644
index 009cdbb..0000000
--- a/BTCPayServer/Security/Bitpay/PairingCodeEntity.cs
+++ /dev/null
@@ -1,43 +0,0 @@
-using System;
-
-namespace BTCPayServer.Security.Bitpay
-{
- public class PairingCodeEntity
- {
- public string Id
- {
- get;
- set;
- }
- public string Label
- {
- get;
- set;
- }
- public string SIN
- {
- get;
- set;
- }
- public DateTimeOffset CreatedTime
- {
- get;
- set;
- }
- public DateTimeOffset Expiration
- {
- get;
- set;
- }
- public string TokenValue
- {
- get;
- set;
- }
-
- public bool IsExpired()
- {
- return DateTimeOffset.UtcNow > Expiration;
- }
- }
-}
diff --git a/BTCPayServer/Security/Bitpay/TokenRepository.cs b/BTCPayServer/Security/Bitpay/TokenRepository.cs
deleted file mode 100644
index aab9715..0000000
--- a/BTCPayServer/Security/Bitpay/TokenRepository.cs
+++ /dev/null
@@ -1,231 +0,0 @@
-using System;
-using System.Linq;
-using System.Threading.Tasks;
-using BTCPayServer.Data;
-using Microsoft.EntityFrameworkCore;
-using NBitcoin;
-using NBitcoin.DataEncoders;
-
-namespace BTCPayServer.Security.Bitpay
-{
- public enum PairingResult
- {
- Partial,
- Complete,
- ReusedKey,
- Expired
- }
-
- public class TokenRepository
- {
- readonly ApplicationDbContextFactory _Factory;
- public TokenRepository(ApplicationDbContextFactory dbFactory)
- {
- ArgumentNullException.ThrowIfNull(dbFactory);
- _Factory = dbFactory;
- }
-
- public async Task<BitTokenEntity[]> GetTokens(string sin)
- {
- if (sin == null)
- return Array.Empty<BitTokenEntity>();
- using var ctx = _Factory.CreateContext();
- return (await ctx.PairedSINData.Where(p => p.SIN == sin)
- .ToArrayAsync())
- .Select(p => CreateTokenEntity(p))
- .ToArray();
- }
-
- public async Task<String> GetStoreIdFromAPIKey(string apiKey)
- {
- using var ctx = _Factory.CreateContext();
- return await ctx.ApiKeys.Where(o => o.Id == apiKey).Select(o => o.StoreId).FirstOrDefaultAsync();
- }
-
- public async Task GenerateLegacyAPIKey(string storeId)
- {
- // It is legacy support and Bitpay generate string of unknown format, trying to replicate them
- // as good as possible. The string below got generated for me.
- var chars = "ERo0vkBMOYhyU0ZHvirCplbLDIGWPdi1ok77VnW7QdE";
- var generated = new char[chars.Length];
- for (int i = 0; i < generated.Length; i++)
- {
- generated[i] = chars[(int)(RandomUtils.GetUInt32() % generated.Length)];
- }
-
- using var ctx = _Factory.CreateContext();
- var existing = await ctx.ApiKeys.Where(o => o.StoreId == storeId && o.Type == APIKeyType.Legacy).ToListAsync();
- if (existing.Any())
- {
- ctx.ApiKeys.RemoveRange(existing);
- }
- ctx.ApiKeys.Add(new APIKeyData() { Id = new string(generated), StoreId = storeId });
- await ctx.SaveChangesAsync().ConfigureAwait(false);
- }
-
- public async Task RevokeLegacyAPIKeys(string storeId)
- {
- var keys = await GetLegacyAPIKeys(storeId);
- if (!keys.Any())
- {
- return;
- }
-
- using var ctx = _Factory.CreateContext();
- ctx.ApiKeys.RemoveRange(keys.Select(s => new APIKeyData() { Id = s }));
- await ctx.SaveChangesAsync();
- }
-
- public async Task<string[]> GetLegacyAPIKeys(string storeId)
- {
- using var ctx = _Factory.CreateContext();
- return await ctx.ApiKeys.Where(o => o.StoreId == storeId && o.Type == APIKeyType.Legacy).Select(c => c.Id).ToArrayAsync();
- }
-
- private BitTokenEntity CreateTokenEntity(PairedSINData data)
- {
- return new BitTokenEntity()
- {
- Label = data.Label,
- Value = data.Id,
- SIN = data.SIN,
- PairingTime = data.PairingTime,
- StoreId = data.StoreDataId
- };
- }
-
- public async Task<string> CreatePairingCodeAsync()
- {
- string pairingCodeId = null;
- while (true)
- {
- pairingCodeId = Encoders.Base58.EncodeData(RandomUtils.GetBytes(6));
- if (pairingCodeId.Length == 7) // woocommerce plugin check for exactly 7 digits
- break;
- }
- using (var ctx = _Factory.CreateContext())
- {
- var now = DateTime.UtcNow;
- var expiration = DateTime.UtcNow + TimeSpan.FromMinutes(15);
- ctx.PairingCodes.Add(new PairingCodeData()
- {
- Id = pairingCodeId,
- DateCreated = now,
- Expiration = expiration,
- TokenValue = Encoders.Base58.EncodeData(RandomUtils.GetBytes(32))
- });
- await ctx.SaveChangesAsync();
- }
- return pairingCodeId;
- }
-
- public async Task<PairingCodeEntity> UpdatePairingCode(PairingCodeEntity pairingCodeEntity)
- {
- using var ctx = _Factory.CreateContext();
- var pairingCode = await ctx.PairingCodes.FindAsync(pairingCodeEntity.Id);
- pairingCode.Label = pairingCodeEntity.Label;
- await ctx.SaveChangesAsync();
- return CreatePairingCodeEntity(pairingCode);
- }
-
- public async Task<PairingResult> PairWithStoreAsync(string pairingCodeId, string storeId)
- {
- using var ctx = _Factory.CreateContext();
- var pairingCode = await ctx.PairingCodes.FindAsync(pairingCodeId);
- if (pairingCode == null || pairingCode.Expiration < DateTimeOffset.UtcNow)
- return PairingResult.Expired;
- pairingCode.StoreDataId = storeId;
- var result = await ActivateIfComplete(ctx, pairingCode);
- await ctx.SaveChangesAsync();
- return result;
- }
-
- public async Task<PairingResult> PairWithSINAsync(string pairingCodeId, string sin)
- {
- using var ctx = _Factory.CreateContext();
- var pairingCode = await ctx.PairingCodes.FindAsync(pairingCodeId);
- if (pairingCode == null || pairingCode.Expiration < DateTimeOffset.UtcNow)
- return PairingResult.Expired;
- pairingCode.SIN = sin;
- var result = await ActivateIfComplete(ctx, pairingCode);
- await ctx.SaveChangesAsync();
- return result;
- }
-
-
- private async Task<PairingResult> ActivateIfComplete(ApplicationDbContext ctx, PairingCodeData pairingCode)
- {
- if (!string.IsNullOrEmpty(pairingCode.SIN) && !string.IsNullOrEmpty(pairingCode.StoreDataId))
- {
- ctx.PairingCodes.Remove(pairingCode);
-
- // Can have concurrency issues... but no harm can be done
- var alreadyUsed = await ctx.PairedSINData.Where(p => p.SIN == pairingCode.SIN && p.StoreDataId != pairingCode.StoreDataId).AnyAsync();
- if (alreadyUsed)
- return PairingResult.ReusedKey;
- await ctx.PairedSINData.AddAsync(new PairedSINData()
- {
- Id = pairingCode.TokenValue,
- PairingTime = DateTime.UtcNow,
- Label = pairingCode.Label,
- StoreDataId = pairingCode.StoreDataId,
- SIN = pairingCode.SIN
- });
- return PairingResult.Complete;
- }
- return PairingResult.Partial;
- }
-
-
- public async Task<BitTokenEntity[]> GetTokensByStoreIdAsync(string storeId)
- {
- using var ctx = _Factory.CreateContext();
- return (await ctx.PairedSINData.Where(p => p.StoreDataId == storeId).ToListAsync())
- .Select(c => CreateTokenEntity(c))
- .ToArray();
- }
-
- public async Task<PairingCodeEntity> GetPairingAsync(string pairingCode)
- {
- using var ctx = _Factory.CreateContext();
- return CreatePairingCodeEntity(await ctx.PairingCodes.FindAsync(pairingCode));
- }
-
- private PairingCodeEntity CreatePairingCodeEntity(PairingCodeData data)
- {
- if (data == null)
- return null;
- return new PairingCodeEntity()
- {
- Id = data.Id,
- Label = data.Label,
- Expiration = data.Expiration,
- CreatedTime = data.DateCreated,
- TokenValue = data.TokenValue,
- SIN = data.SIN
- };
- }
-
-
- public async Task<bool> DeleteToken(string tokenId)
- {
- using var ctx = _Factory.CreateContext();
- var token = await ctx.PairedSINData.FindAsync(tokenId);
- if (token == null)
- return false;
- ctx.PairedSINData.Remove(token);
- await ctx.SaveChangesAsync();
- return true;
- }
-
- public async Task<BitTokenEntity> GetToken(string tokenId)
- {
- using var ctx = _Factory.CreateContext();
- var token = await ctx.PairedSINData.FindAsync(tokenId);
- if (token == null)
- return null;
- return CreateTokenEntity(token);
- }
-
- }
-}
diff --git a/BTCPayServer/Security/GreenField/APIKeysAuthenticationHandler.cs b/BTCPayServer/Security/GreenField/APIKeysAuthenticationHandler.cs
index ddb9025..4f6d81c 100644
--- a/BTCPayServer/Security/GreenField/APIKeysAuthenticationHandler.cs
+++ b/BTCPayServer/Security/GreenField/APIKeysAuthenticationHandler.cs
@@ -11,6 +11,7 @@ using BTCPayServer.Data;
using BTCPayServer.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -20,13 +21,13 @@ namespace BTCPayServer.Security.Greenfield
APIKeyRepository apiKeyRepository,
IOptionsMonitor<IdentityOptions> identityOptions,
IOptionsMonitor<GreenfieldAuthenticationOptions> options,
+ IOptions<MvcNewtonsoftJsonOptions> mvcOptions,
ILoggerFactory logger,
UrlEncoder encoder,
UserService userService,
UserManager<ApplicationUser> userManager)
- : AuthenticationHandler<GreenfieldAuthenticationOptions>(options, logger, encoder)
+ : GreenfieldAuthenticationHandler(options, logger, encoder, mvcOptions)
{
- public const string AuthFailureReason = "Greenfield-" + nameof(AuthFailureReason);
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
{
// This one deserve some explanation...
@@ -66,7 +67,7 @@ namespace BTCPayServer.Security.Greenfield
AuthenticateResult Fail(string reason)
{
- Context.Items.TryAdd(AuthFailureReason, reason);
+ Context.Items.TryAdd(GreenfieldAuthenticationHandler.GreenfieldAuthFailureReason, reason);
return AuthenticateResult.Fail(reason);
}
}
diff --git a/BTCPayServer/Security/GreenField/BasicAuthenticationHandler.cs b/BTCPayServer/Security/GreenField/BasicAuthenticationHandler.cs
index 19d442d..7ab98ad 100644
--- a/BTCPayServer/Security/GreenField/BasicAuthenticationHandler.cs
+++ b/BTCPayServer/Security/GreenField/BasicAuthenticationHandler.cs
@@ -11,6 +11,7 @@ using BTCPayServer.Data;
using BTCPayServer.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -21,13 +22,14 @@ namespace BTCPayServer.Security.Greenfield
public class BasicAuthenticationHandler(
IOptionsMonitor<IdentityOptions> identityOptions,
IOptionsMonitor<GreenfieldAuthenticationOptions> options,
+ IOptions<MvcNewtonsoftJsonOptions> mvcOptions,
ILoggerFactory logger,
UrlEncoder encoder,
SignInManager<ApplicationUser> signInManager,
UserService userService,
IRateLimitService rateLimitService,
UserManager<ApplicationUser> userManager)
- : AuthenticationHandler<GreenfieldAuthenticationOptions>(options, logger, encoder)
+ : GreenfieldAuthenticationHandler(options, logger, encoder, mvcOptions)
{
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
@@ -93,7 +95,7 @@ namespace BTCPayServer.Security.Greenfield
AuthenticateResult Fail(string reason)
{
- Context.Items.TryAdd(APIKeysAuthenticationHandler.AuthFailureReason, reason);
+ Context.Items.TryAdd(GreenfieldAuthenticationHandler.GreenfieldAuthFailureReason, reason);
return AuthenticateResult.Fail(reason);
}
}
diff --git a/BTCPayServer/Security/GreenField/GreenfieldAuthenticationHandler.cs b/BTCPayServer/Security/GreenField/GreenfieldAuthenticationHandler.cs
new file mode 100644
index 0000000..a3e0694
--- /dev/null
+++ b/BTCPayServer/Security/GreenField/GreenfieldAuthenticationHandler.cs
@@ -0,0 +1,55 @@
+#nullable enable
+using System.Globalization;
+using System.Text;
+using System.Text.Encodings.Web;
+using System.Threading.Tasks;
+using BTCPayServer.Client.Models;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Newtonsoft.Json;
+
+namespace BTCPayServer.Security.Greenfield;
+
+public abstract class GreenfieldAuthenticationHandler(
+ IOptionsMonitor<GreenfieldAuthenticationOptions> options,
+ ILoggerFactory logger,
+ UrlEncoder encoder,
+ IOptions<MvcNewtonsoftJsonOptions> mvcOptions)
+ : AuthenticationHandler<GreenfieldAuthenticationOptions>(options, logger, encoder)
+{
+ public const string GreenfieldAuthFailureReason = nameof(GreenfieldAuthFailureReason);
+
+ protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
+ {
+ Context.Items.TryGetValue(GreenfieldAuthFailureReason, out var reason);
+ var reasonStr = reason as string ?? "Authentication is required for accessing this endpoint";
+ await WriteError(new GreenfieldAPIError("unauthenticated", reasonStr), 401);
+ }
+
+ protected override async Task HandleForbiddenAsync(AuthenticationProperties properties)
+ {
+ if (Context.Items.TryGetValue(GreenfieldAuthorizationHandler.RequestedPermissionKey, out var p) &&
+ p is string policy)
+ {
+ await WriteError(new GreenfieldPermissionAPIError(policy), 403);
+ }
+ else
+ {
+ await base.HandleForbiddenAsync(properties);
+ }
+ }
+
+ private async Task WriteError(object outputObj, int httpCode)
+ {
+ if (Context.Response.HasStarted)
+ return;
+ var output = JsonConvert.SerializeObject(outputObj, mvcOptions.Value.SerializerSettings);
+ var outputBytes = new UTF8Encoding(false).GetBytes(output);
+ Context.Response.ContentType = "application/json";
+ Context.Response.ContentLength = outputBytes.Length;
+ Context.Response.StatusCode = httpCode;
+ await Context.Response.Body.WriteAsync(outputBytes, 0, outputBytes.Length);
+ }
+}
diff --git a/BTCPayServer/Services/Invoices/InvoiceEntity.cs b/BTCPayServer/Services/Invoices/InvoiceEntity.cs
index 0ccf97e..378c610 100644
--- a/BTCPayServer/Services/Invoices/InvoiceEntity.cs
+++ b/BTCPayServer/Services/Invoices/InvoiceEntity.cs
@@ -19,12 +19,10 @@ using Microsoft.AspNetCore.Mvc;
using NBitcoin;
using NBitcoin.DataEncoders;
using NBitpayClient;
-using NBXplorer;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Serialization;
-using static BTCPayServer.Controllers.BitpayRateController;
namespace BTCPayServer.Services.Invoices
{
diff --git a/BTCPayServer/Views/UIStores/CreateToken.cshtml b/BTCPayServer/Views/UIStores/CreateToken.cshtml
deleted file mode 100644
index 86213c2..0000000
--- a/BTCPayServer/Views/UIStores/CreateToken.cshtml
+++ /dev/null
@@ -1,64 +0,0 @@
-@model CreateTokenViewModel
-@{
- var store = Context.GetStoreData();
- ViewData.SetLayoutModel(new(nameof(StoreNavPages.Tokens), StringLocalizer["Create New Token"]));
- ViewBag.HidePublicKey ??= false;
- ViewBag.ShowStores ??= false;
-}
-
-<form method="post">
- <div class="sticky-header">
- @if (store != null)
- {
- <nav aria-label="breadcrumb">
- <ol class="breadcrumb">
- <li class="breadcrumb-item">
- <a asp-action="ListTokens" asp-route-storeId="@store.Id" text-translate="true">Access Tokens</a>
- </li>
- <li class="breadcrumb-item active" aria-current="page">@ViewData["Title"]</li>
- </ol>
- <h2>@ViewData["Title"]</h2>
- </nav>
- }
- else
- {
- <h2>@ViewData["Title"]</h2>
- }
- <input id="page-primary" type="submit" value="Request Pairing" class="btn btn-primary" />
- </div>
- <partial name="_StatusMessage" />
- <div class="row">
- <div class="col-xxl-constrain col-xl-8">
- <div class="form-group">
- <label asp-for="Label" class="form-label"></label>
- @if (ViewBag.HidePublicKey)
- {
- <small class="text-muted" text-translate="true">optional</small>
- }
- <input asp-for="Label" class="form-control" />
- <span asp-validation-for="Label" class="text-danger"></span>
- </div>
- @if (!ViewBag.HidePublicKey)
- {
- <div class="form-group">
- <label asp-for="PublicKey" class="form-label"></label>
- <input asp-for="PublicKey" class="form-control" />
- <span asp-validation-for="PublicKey" class="text-danger"></span>
- <div class="form-text" text-translate="true">Keep empty for server-initiated pairing.</div>
- </div>
- }
- @if (ViewBag.ShowStores)
- {
- <div class="form-group">
- <label asp-for="Stores" class="form-label"></label>
- <select asp-for="StoreId" asp-items="Model.Stores" class="form-select"></select>
- <span asp-validation-for="StoreId" class="text-danger"></span>
- </div>
- }
- else
- {
- <input type="hidden" asp-for="StoreId" />
- }
- </div>
- </div>
-</form>
diff --git a/BTCPayServer/Views/UIStores/ListTokens.cshtml b/BTCPayServer/Views/UIStores/ListTokens.cshtml
deleted file mode 100644
index 357d8fb..0000000
--- a/BTCPayServer/Views/UIStores/ListTokens.cshtml
+++ /dev/null
@@ -1,104 +0,0 @@
-@using BTCPayServer.Client
-@using Microsoft.AspNetCore.Mvc.TagHelpers
-@model TokensViewModel
-@{
- ViewData.SetLayoutModel(new LayoutModel(nameof(StoreNavPages.Tokens), StringLocalizer["Access Tokens"]).SetCategory(WellKnownCategories.Store));
-}
-
-@if (Model.StoreNotConfigured)
-{
- <div class="alert alert-warning alert-dismissible mb-5" role="alert">
- <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="@StringLocalizer["Close"]">
- <vc:icon symbol="close" />
- </button>
- <span text-translate="true">Warning: No wallet has been linked to your BTCPay Server Store.</span><br/>
- See <a href="https://docs.btcpayserver.org/WalletSetup/" target="_blank" class="alert-link" rel="noreferrer noopener">this link</a> for more information on how to connect your store and wallet.
- </div>
-}
-<div class="sticky-header">
- <h2 class="my-1">
- <span text-translate="true">Greenfield API Keys</span>
- <a href="/docs" target="_blank" rel="noreferrer noopener" title="@StringLocalizer["More information..."]">
- <vc:icon symbol="info"/>
- </a>
- </h2>
-</div>
-
-<partial name="_StatusMessage" />
-<div class="row">
- <div class="col-xxl-constrain col-xl-8">
- <p>
- <span text-translate="true">To generate Greenfield API keys, please</span>
- <a asp-controller="UIManage" asp-action="APIKeys" text-translate="true">click here</a>.
- </p>
-
- <div class="d-flex align-items-center justify-content-between mt-5 mb-3">
- <h3 class="mb-0">@ViewData["Title"]</h3>
- <a id="CreateNewToken" asp-action="CreateToken" class="btn btn-primary" role="button" asp-route-storeId="@Context.GetRouteValue("storeId")" permission="@Policies.CanModifyStoreSettings" text-translate="true">
- Create Token
- </a>
- </div>
- <p>
- <span text-translate="true">Authorize a public key to access Bitpay compatible Invoice API.</span>
- <a href="https://support.bitpay.com/hc/en-us/articles/115003001183-How-do-I-pair-my-client-and-create-a-token-" target="_blank" rel="noreferrer noopener" title="@StringLocalizer["More information..."]">
- <vc:icon symbol="info" />
- </a>
- </p>
-
- @if (Model.Tokens.Any())
- {
- <div class="table-responsive-md">
- <table class="table table-hover">
- <thead>
- <tr>
- <th text-translate="true">Label</th>
- <th class="text-end" permission="@Policies.CanModifyStoreSettings" text-translate="true">Actions</th>
- </tr>
- </thead>
- <tbody>
- @foreach (var token in Model.Tokens)
- {
- <tr>
- <td>@token.Label</td>
- <td class="text-end" permission="@Policies.CanModifyStoreSettings">
- <a asp-action="ShowToken" asp-route-storeId="@Context.GetRouteValue("storeId")" asp-route-tokenId="@token.Id" text-translate="true">See information</a> -
- <a asp-action="RevokeToken" asp-route-storeId="@Context.GetRouteValue("storeId")" asp-route-tokenId="@token.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-description="The access token with the label <strong>@Html.Encode(token.Label)</strong> will be revoked." data-confirm-input="REVOKE" text-translate="true">Revoke</a>
- </td>
- </tr>
- }
- </tbody>
- </table>
- </div>
- }
- else
- {
- <p class="text-secondary mt-3" text-translate="true">
- No access tokens yet.
- </p>
- }
-
- <h3 class="mt-5 mb-3" text-translate="true">Legacy API Keys</h3>
- <p text-translate="true">Alternatively, you can use the invoice API by including the following HTTP Header in your requests:</p>
- <p><code>Authorization: Basic @Model.EncodedApiKey</code></p>
-
- <form method="post" asp-action="GenerateAPIKey" asp-route-storeId="@Context.GetRouteValue("storeId")" permission="@Policies.CanModifyStoreSettings">
- <div class="form-group">
- <label asp-for="ApiKey" class="form-label"></label>
- <div class="d-flex">
- <input asp-for="ApiKey" readonly class="form-control"/>
- @if (string.IsNullOrEmpty(Model.ApiKey))
- {
- <button class="btn btn-primary ms-3" type="submit" text-translate="true">Generate</button>
- }
- else
- {
- <button class="btn btn-danger ms-3" type="submit" name="command" value="revoke" text-translate="true">Revoke</button>
- <button class="btn btn-primary ms-3" type="submit" text-translate="true">Regenerate</button>
- }
- </div>
- </div>
- </form>
- </div>
-</div>
-
-<partial name="_Confirm" model="@(new ConfirmModel(StringLocalizer["Revoke access token"], StringLocalizer["The access token will be revoked. Do you wish to continue?"], StringLocalizer["Revoke"]))" permission="@Policies.CanModifyStoreSettings" />
diff --git a/BTCPayServer/Views/UIStores/RequestPairing.cshtml b/BTCPayServer/Views/UIStores/RequestPairing.cshtml
deleted file mode 100644
index 185b4c9..0000000
--- a/BTCPayServer/Views/UIStores/RequestPairing.cshtml
+++ /dev/null
@@ -1,70 +0,0 @@
-@using BTCPayServer.Abstractions.TagHelpers
-@using BTCPayServer.Client
-@using Microsoft.AspNetCore.Mvc.TagHelpers
-@model PairingModel
-@{
- var store = Context.GetStoreData();
- Layout = store is null ? "_LayoutWizard" : "_Layout";
- ViewData.SetLayoutModel(new(nameof(StoreNavPages.Tokens), StringLocalizer["Pairing Permission"]));
-}
-
-@if (store is null)
-{
- @section Navbar {
- <button type="button" class="cancel" onclick="history.back()">
- <vc:icon symbol="cross" />
- </button>
- }
-}
-<form asp-action="Pair" method="post" permissioned="@Policies.CanModifyStoreSettings">
- <div class="sticky-header">
- @if (store != null)
- {
- <nav aria-label="breadcrumb">
- <ol class="breadcrumb">
- <li class="breadcrumb-item">
- <a asp-action="ListTokens" asp-route-storeId="@store.Id" text-translate="true">Access Tokens</a>
- </li>
- <li class="breadcrumb-item">
- <a asp-action="CreateToken" asp-route-storeId="@store.Id" text-translate="true">Create Token</a>
- </li>
- <li class="breadcrumb-item active" aria-current="page">@ViewData["Title"]</li>
- </ol>
- <h2>@ViewData["Title"]</h2>
- </nav>
- }
- else
- {
- <h2>@ViewData["Title"]</h2>
- }
- <button id="page-primary" type="submit" class="btn btn-primary mt-3" title="@StringLocalizer["Approve this pairing demand"]" text-translate="true">Approve</button>
- </div>
- <partial name="_StatusMessage" />
- <div class="row">
- <div class="col-sm-8 col-md-6 @(store is null ? "mx-auto" : "")">
- <table class="table table-hover">
- <tr>
- <th text-translate="true">Label</th>
- <td class="text-end">@Model.Label</td>
- </tr>
- <tr>
- <th text-translate="true">SIN</th>
- <td class="text-end">@Model.SIN</td>
- </tr>
- </table>
- @if (store is null)
- {
- <div class="form-group">
- <label asp-for="StoreId" class="form-label" text-translate="true">Pair To Store</label>
- <select asp-for="StoreId" asp-items="@(new SelectList(Model.Stores, "Id", "Name"))" class="form-select"></select>
- <span asp-validation-for="StoreId" class="text-danger"></span>
- </div>
- }
- else
- {
- <input asp-for="StoreId" type="hidden" />
- }
- <input type="hidden" name="pairingCode" value="@Model.Id"/>
- </div>
- </div>
-</form>
diff --git a/BTCPayServer/Views/UIStores/ShowToken.cshtml b/BTCPayServer/Views/UIStores/ShowToken.cshtml
deleted file mode 100644
index 1af552c..0000000
--- a/BTCPayServer/Views/UIStores/ShowToken.cshtml
+++ /dev/null
@@ -1,25 +0,0 @@
-@model BTCPayServer.Security.Bitpay.BitTokenEntity
-@{
- ViewData.SetLayoutModel(new(nameof(StoreNavPages.Tokens), StringLocalizer["Access Tokens"]));
-}
-<h2 class="mb-2 mb-lg-3">@ViewData["Title"]</h2>
-<partial name="_StatusMessage" />
-<div class="row">
- <div class="col-md-8">
- <h5 text-translate="true">Token Information</h5>
- <table class="table table-hover">
- <tr>
- <th text-translate="true">Label</th>
- <td>@Model.Label</td>
- </tr>
- <tr>
- <th text-translate="true">SIN</th>
- <td>@Model.SIN</td>
- </tr>
- <tr>
- <th text-translate="true">Token</th>
- <td>@Model.Value</td>
- </tr>
- </table>
- </div>
-</div>
diff --git a/btcpayserver.sln.DotSettings b/btcpayserver.sln.DotSettings
index 14746a8..7e913dc 100644
--- a/btcpayserver.sln.DotSettings
+++ b/btcpayserver.sln.DotSettings
@@ -4,6 +4,7 @@
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=BTC/@EntryIndexedValue">BTC</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=CPFP/@EntryIndexedValue">CPFP</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=HWI/@EntryIndexedValue">HWI</s:String>
+ <s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=IPN/@EntryIndexedValue">IPN</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=LN/@EntryIndexedValue">LN</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=LNURL/@EntryIndexedValue">LNURL</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=NB/@EntryIndexedValue">NBX</s:String>
@@ -15,6 +16,7 @@
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=SSH/@EntryIndexedValue">SSH</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=TX/@EntryIndexedValue">TX</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=UI/@EntryIndexedValue">UI</s:String>
+ <s:Boolean x:Key="/Default/UserDictionary/Words/=Bitpay/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=btcpay/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=coinjoin/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=lnurlw/@EntryIndexedValue">True</s:Boolean>
Why this scored 35/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.