Compare commits
289 Commits
v1.0.3.163
...
v1.0.4.0
Author | SHA1 | Date | |
---|---|---|---|
dfe655393d | |||
8c81dae167 | |||
de75d30f06 | |||
8ba99d4e7c | |||
c3bc25a7d4 | |||
42be03b560 | |||
e73aece9c3 | |||
69c57867b3 | |||
7434163848 | |||
00d1c4ebcc | |||
26067fbfe2 | |||
03458efea4 | |||
bd21bf9c0f | |||
5b7a20c33e | |||
c73c34dfaa | |||
a3a9361ba5 | |||
2f0e9569a1 | |||
511a0efa89 | |||
4ae91ba307 | |||
3a70f467eb | |||
4e09bb0b01 | |||
5ae18cf21f | |||
6bfb6a795e | |||
1d2540543b | |||
9efe6267d3 | |||
cb10551d2c | |||
b0073af5aa | |||
7ca7f53446 | |||
ad284a4b61 | |||
95e7d5ded9 | |||
55722b3191 | |||
5e34efc9f4 | |||
6f85ffd9df | |||
9783a76c38 | |||
05952f95f1 | |||
b9c97cc5d7 | |||
d5b088b924 | |||
fbd5673cfd | |||
dbb7ad083a | |||
ce7e4234cc | |||
d2b38fdfce | |||
fcdcc5e69b | |||
8d73606809 | |||
15a7c4d092 | |||
c205e41072 | |||
48c220b751 | |||
06ff268644 | |||
9ee920a816 | |||
a403363015 | |||
6274958409 | |||
d47e225dce | |||
841cf61c92 | |||
2d2c5b46af | |||
cc80e4636f | |||
bb24c95e71 | |||
c7a4158a39 | |||
ed0e423aa7 | |||
1c0d713b00 | |||
70c80f4d44 | |||
e3f6de8472 | |||
95644f8884 | |||
f57db12c09 | |||
0d821ff4db | |||
6927d81175 | |||
e183714475 | |||
6602823067 | |||
111feeb673 | |||
3bf1b78b33 | |||
751ccc333f | |||
624e6e4744 | |||
73b13c750d | |||
148b04e9ba | |||
32938479ac | |||
aae0086e68 | |||
1f4556bd9d | |||
d7bb15cac3 | |||
9a54445785 | |||
523edfef58 | |||
5a841216b8 | |||
d6d58a98db | |||
6a0cda69c9 | |||
24691e5290 | |||
b203d369fb | |||
b1cc30d25d | |||
72e64885be | |||
f4a47f5197 | |||
fe45152529 | |||
9a9773853e | |||
9d2ab8b154 | |||
ba2184e21a | |||
829b0dd5e2 | |||
9fc451c9ca | |||
452568e740 | |||
5503132ffc | |||
17a6b7d34f | |||
69ad9edc9a | |||
80bb959ac3 | |||
1e0587af26 | |||
16e35e8b55 | |||
8278926e42 | |||
1debbc3cdb | |||
65c99ead1d | |||
2693dacae6 | |||
7ce614f1c4 | |||
79a0f97abb | |||
670e0ee7df | |||
3f231a8894 | |||
f5dfee7642 | |||
ab120c5dcb | |||
f085a5618b | |||
d939baac84 | |||
41d70e8462 | |||
9af7edf8b8 | |||
01a8c20ee8 | |||
4c966e2a09 | |||
42aead3c89 | |||
a348960041 | |||
c737a25234 | |||
a01b2e4a83 | |||
5f838db281 | |||
08beffb005 | |||
76b919d887 | |||
c106ac2c42 | |||
4a1fb71e09 | |||
98e2baae19 | |||
963c69a0e0 | |||
fd026a9733 | |||
e76785a64e | |||
1a8f222e46 | |||
24d26d7a44 | |||
c86370c25a | |||
20cba1d3a1 | |||
3e2efc7f27 | |||
d2c29aaec6 | |||
bb1c5dead5 | |||
41cc79600a | |||
238d4fceea | |||
c6d75de3d7 | |||
9e1ae29600 | |||
d60b00e8cd | |||
49786f4195 | |||
7b6eae6053 | |||
a408541eb3 | |||
1ba25448cc | |||
4d2e59e1a1 | |||
7b4f686add | |||
ee0ef2881a | |||
22f79e9fe4 | |||
fdad5a47d5 | |||
e32f3cbf80 | |||
b56d026fdb | |||
64717328f6 | |||
065be9be64 | |||
10fcfab233 | |||
ff9865c516 | |||
59bae2c337 | |||
89d9793692 | |||
23b2f55b47 | |||
886510c2e1 | |||
2b11b43d6d | |||
d90ffb2254 | |||
fc88a867fa | |||
e4cb1a875b | |||
1a62ee9260 | |||
56d5e6f99f | |||
f1821636db | |||
2e3a0706ee | |||
89da4184ff | |||
1895e154d9 | |||
6d7b57ea3b | |||
39a8c3fe47 | |||
927c09ff7b | |||
08abda1522 | |||
d219ba5d32 | |||
afdee9d8a2 | |||
ac14f199e4 | |||
76818fa385 | |||
49be370e51 | |||
fbe89f1784 | |||
b7afcb90a2 | |||
a6ac67963e | |||
bde8ed7aa2 | |||
ca234838a3 | |||
d54d340bef | |||
a926a5eedf | |||
0df5e7d7a3 | |||
034fb4ec80 | |||
69482eb4fb | |||
10e52f08be | |||
5565d8dae5 | |||
c633402fe2 | |||
0688feea3c | |||
c906fd42df | |||
6468b39121 | |||
d0a95f5a69 | |||
e36338d903 | |||
e596513fc1 | |||
77588182b9 | |||
ca00caa4a4 | |||
36bd76248b | |||
f0f05acdfd | |||
6df7ffd7e2 | |||
91924512e6 | |||
7899c2d5c5 | |||
56ba834ca2 | |||
d57fdd4785 | |||
805e1f53b3 | |||
40953ef2c6 | |||
ff055c08fb | |||
f3d5cf3622 | |||
e48e8c34d9 | |||
98a48cd0a5 | |||
f8f358ebdb | |||
9d99c32305 | |||
478b1463ff | |||
7e7f0053e2 | |||
9a940a044e | |||
d2864ccd7c | |||
ad4dbdad6d | |||
094307d688 | |||
53e7c84e73 | |||
2a865284da | |||
4666238e38 | |||
b54a7b80e3 | |||
432d6bb261 | |||
fb36ed2cae | |||
55516a3253 | |||
a0e638d500 | |||
2def9e7bd3 | |||
0bfc12ae3d | |||
318d826694 | |||
44b3bb34a4 | |||
46edc281b6 | |||
d72139c2c1 | |||
29a807696b | |||
517c65f1fc | |||
8f18be727b | |||
d6c66d0c03 | |||
eac33d494a | |||
2105b44610 | |||
ab74013a05 | |||
967b02e373 | |||
8432cd5477 | |||
ccfca65c41 | |||
0a8abaf7d5 | |||
47c1164003 | |||
65d26ad8a1 | |||
0a0d8d53a4 | |||
e50e3f662d | |||
540a31207e | |||
132c36df7b | |||
e351e0c9ea | |||
8d7b9fcef2 | |||
6e1f3989e8 | |||
e99767c7e2 | |||
c85fb3e89f | |||
348934488d | |||
6c8918a308 | |||
ff2ea5815c | |||
cc0202ecb3 | |||
0c065df4bd | |||
b5664dac81 | |||
8173296c96 | |||
71a00c0e67 | |||
70b172addc | |||
2002c6750b | |||
786be9d1f5 | |||
233fa8a4a1 | |||
c74f52a61c | |||
245507f821 | |||
5495c4b5d3 | |||
afd2c8e3d7 | |||
c8e1db2102 | |||
95f859b6db | |||
6bf7ef0798 | |||
42152050a3 | |||
67befcc629 | |||
3cdf881438 | |||
153992a458 | |||
691a8d6fd8 | |||
a9bf843be0 | |||
60e5afe690 | |||
980bedf301 | |||
6f6e8ba1a1 | |||
d3af82e38b | |||
65afc9f7b2 | |||
2e630ac5d8 | |||
e6acc19bcc | |||
c598a1827f |
BTCPayServer.Client
BTCPayServer.Client.csprojBTCPayServerClient.APIKeys.csBTCPayServerClient.Authorization.csBTCPayServerClient.Users.csBTCPayServerClient.cs
JsonConverters
Models
Permissions.csBTCPayServer.Common
Altcoins
BTCPayNetwork.csBTCPayServer.Common.csprojBTCPayServer.Data
BTCPayServer.Data.csproj
Data
APIKeyData.csAppData.csApplicationDbContext.csOffchainTransactionData.csPayjoinLock.csPaymentData.csPlannedTransaction.cs
Migrations
BTCPayServer.Rating
BTCPayServer.Tests
ApiKeysTests.csBTCPayServer.Tests.csprojBTCPayServerTester.csCheckoutUITests.csElementsTests.csExtensions.csGreenfieldAPITests.csPayJoinTests.csPaymentHandlerTest.csSeleniumTester.csSeleniumTests.csServerTester.csTestAccount.csTestUtils.csUnitTest1.csdocker-bitcoin-generate.shdocker-compose.monero.ymldocker-compose.yml
BTCPayServer
BTCPayServer.csprojZoneLimits.csbundleconfig.json
Controllers
AccountController.csAppsController.csAppsPublicController.cs
GreenField
HomeController.csInvoiceController.API.csInvoiceController.UI.csInvoiceController.csManageController.2FA.csManageController.APIKeys.csManageController.csPaymentRequestController.csRateController.csRestApi/ApiKeys
ServerController.csStoresController.BTCLike.csStoresController.csVaultController.csWalletsController.PSBT.csWalletsController.csData
Events
Extensions.csExtensions
HostedServices
CssThemeManager.csDelayedTransactionBroadcasterHostedService.csNBXplorerWaiter.csSocks5HttpProxyServer.csUserEventHostedService.cs
Hosting
JsonConverters
Models
AccountViewModels
AppViewModels
InvoicingModels
PaymentRequestViewModels
StoreViewModels
WalletViewModels
Payments
Bitcoin
BitcoinLikeOnChainPaymentMethod.csBitcoinLikePaymentData.csBitcoinLikePaymentHandler.csNBXplorerListener.cs
IPaymentMethodHandler.csLightning
PayJoin
PaymentTypes.Bitcoin.csProperties
Security
APIKeys
AuthenticationSchemes.csBitpay
CookieAuthorizationHandler.csGreenField
APIKeyExtensions.csAPIKeyRepository.csAPIKeysAuthenticationHandler.csBasicAuthenticationHandler.csGreenFieldAuthenticationOptions.csGreenFieldAuthorizationHandler.csGreenFieldConstants.cs
Policies.csServerPolicies.csServices
Altcoins/Monero
Payments
Services
UI
Apps
DelayedTransactionBroadcaster.csFees
Invoices
Mails
PayjoinClient.csPoliciesSettings.csSocketFactory.csSocks5HttpClientHandler.csWallets
Views
AppsPublic
Error
Home
Invoice
Manage
PaymentRequest
Server
Shared
Bitcoin_Lightning_LikeMethodCheckout.cshtmlViewBitcoinLikePaymentData.cshtml_BTCPaySupporters.cshtml_Layout.cshtml
Stores
AddDerivationSchemes_HardwareWalletDialogs.cshtmlAddDerivationSchemes_NBXWalletGenerate.cshtmlCheckoutExperience.cshtmlPayButton.cshtmlUpdateStore.cshtml
Wallets
wwwroot
_bootstrap_kitchensink.html
cart/css
checkout/css
img
imlegacy
js
main
paybutton
swagger/v1
swagger.template.api-keys.jsonswagger.template.authorization.jsonswagger.template.jsonswagger.template.users.json
vendor/vue-qrcode-reader
Build
Changelog.mdREADME.mdamd64.Dockerfilearm32v7.Dockerfilearm64v8.Dockerfilebtcpayserver.slnnuget.config
12
BTCPayServer.Client/BTCPayServer.Client.csproj
Normal file
12
BTCPayServer.Client/BTCPayServer.Client.csproj
Normal file
@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="NBitcoin" Version="5.0.29" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
39
BTCPayServer.Client/BTCPayServerClient.APIKeys.cs
Normal file
39
BTCPayServer.Client/BTCPayServerClient.APIKeys.cs
Normal file
@ -0,0 +1,39 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Client.Models;
|
||||
|
||||
namespace BTCPayServer.Client
|
||||
{
|
||||
public partial class BTCPayServerClient
|
||||
{
|
||||
public virtual async Task<ApiKeyData> GetCurrentAPIKeyInfo(CancellationToken token = default)
|
||||
{
|
||||
var response = await _httpClient.SendAsync(CreateHttpRequest("api/v1/api-keys/current"), token);
|
||||
return await HandleResponse<ApiKeyData>(response);
|
||||
}
|
||||
|
||||
public virtual async Task<ApiKeyData> CreateAPIKey(CreateApiKeyRequest request, CancellationToken token = default)
|
||||
{
|
||||
if (request == null)
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
var response = await _httpClient.SendAsync(CreateHttpRequest("api/v1/api-keys", bodyPayload: request, method: HttpMethod.Post), token);
|
||||
return await HandleResponse<ApiKeyData>(response);
|
||||
}
|
||||
|
||||
public virtual async Task RevokeCurrentAPIKeyInfo(CancellationToken token = default)
|
||||
{
|
||||
var response = await _httpClient.SendAsync(CreateHttpRequest("api/v1/api-keys/current", null, HttpMethod.Delete), token);
|
||||
HandleResponse(response);
|
||||
}
|
||||
|
||||
public virtual async Task RevokeAPIKey(string apikey, CancellationToken token = default)
|
||||
{
|
||||
if (apikey == null)
|
||||
throw new ArgumentNullException(nameof(apikey));
|
||||
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/api-keys/{apikey}", null, HttpMethod.Delete), token);
|
||||
HandleResponse(response);
|
||||
}
|
||||
}
|
||||
}
|
24
BTCPayServer.Client/BTCPayServerClient.Authorization.cs
Normal file
24
BTCPayServer.Client/BTCPayServerClient.Authorization.cs
Normal file
@ -0,0 +1,24 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace BTCPayServer.Client
|
||||
{
|
||||
public partial class BTCPayServerClient
|
||||
{
|
||||
|
||||
public static Uri GenerateAuthorizeUri(Uri btcpayHost, string[] permissions, bool strict = true,
|
||||
bool selectiveStores = false)
|
||||
{
|
||||
var result = new UriBuilder(btcpayHost);
|
||||
result.Path = "api-keys/authorize";
|
||||
|
||||
AppendPayloadToQuery(result,
|
||||
new Dictionary<string, object>()
|
||||
{
|
||||
{"strict", strict}, {"selectiveStores", selectiveStores}, {"permissions", permissions}
|
||||
});
|
||||
|
||||
return result.Uri;
|
||||
}
|
||||
}
|
||||
}
|
23
BTCPayServer.Client/BTCPayServerClient.Users.cs
Normal file
23
BTCPayServer.Client/BTCPayServerClient.Users.cs
Normal file
@ -0,0 +1,23 @@
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Client.Models;
|
||||
|
||||
namespace BTCPayServer.Client
|
||||
{
|
||||
public partial class BTCPayServerClient
|
||||
{
|
||||
public virtual async Task<ApplicationUserData> GetCurrentUser(CancellationToken token = default)
|
||||
{
|
||||
var response = await _httpClient.SendAsync(CreateHttpRequest("api/v1/users/me"), token);
|
||||
return await HandleResponse<ApplicationUserData>(response);
|
||||
}
|
||||
|
||||
public virtual async Task<ApplicationUserData> CreateUser(CreateApplicationUserRequest request,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
var response = await _httpClient.SendAsync(CreateHttpRequest("api/v1/users", null, request, HttpMethod.Post), token);
|
||||
return await HandleResponse<ApplicationUserData>(response);
|
||||
}
|
||||
}
|
||||
}
|
117
BTCPayServer.Client/BTCPayServerClient.cs
Normal file
117
BTCPayServer.Client/BTCPayServerClient.cs
Normal file
@ -0,0 +1,117 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace BTCPayServer.Client
|
||||
{
|
||||
public partial class BTCPayServerClient
|
||||
{
|
||||
private readonly string _apiKey;
|
||||
private readonly Uri _btcpayHost;
|
||||
private readonly string _username;
|
||||
private readonly string _password;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public string APIKey => _apiKey;
|
||||
|
||||
public BTCPayServerClient(Uri btcpayHost, HttpClient httpClient = null)
|
||||
{
|
||||
if (btcpayHost == null)
|
||||
throw new ArgumentNullException(nameof(btcpayHost));
|
||||
_btcpayHost = btcpayHost;
|
||||
_httpClient = httpClient ?? new HttpClient();
|
||||
}
|
||||
public BTCPayServerClient(Uri btcpayHost, string APIKey, HttpClient httpClient = null)
|
||||
{
|
||||
_apiKey = APIKey;
|
||||
_btcpayHost = btcpayHost;
|
||||
_httpClient = httpClient ?? new HttpClient();
|
||||
}
|
||||
|
||||
public BTCPayServerClient(Uri btcpayHost, string username, string password, HttpClient httpClient = null)
|
||||
{
|
||||
_apiKey = APIKey;
|
||||
_btcpayHost = btcpayHost;
|
||||
_username = username;
|
||||
_password = password;
|
||||
_httpClient = httpClient ?? new HttpClient();
|
||||
}
|
||||
|
||||
protected void HandleResponse(HttpResponseMessage message)
|
||||
{
|
||||
message.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
protected async Task<T> HandleResponse<T>(HttpResponseMessage message)
|
||||
{
|
||||
HandleResponse(message);
|
||||
return JsonConvert.DeserializeObject<T>(await message.Content.ReadAsStringAsync());
|
||||
}
|
||||
|
||||
protected virtual HttpRequestMessage CreateHttpRequest(string path,
|
||||
Dictionary<string, object> queryPayload = null,
|
||||
HttpMethod method = null)
|
||||
{
|
||||
UriBuilder uriBuilder = new UriBuilder(_btcpayHost) {Path = path};
|
||||
if (queryPayload != null && queryPayload.Any())
|
||||
{
|
||||
AppendPayloadToQuery(uriBuilder, queryPayload);
|
||||
}
|
||||
|
||||
var httpRequest = new HttpRequestMessage(method ?? HttpMethod.Get, uriBuilder.Uri);
|
||||
if (_apiKey != null)
|
||||
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("token", _apiKey);
|
||||
else if (!string.IsNullOrEmpty(_username))
|
||||
{
|
||||
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Basic", System.Convert.ToBase64String(Encoding.ASCII.GetBytes(_username + ":" + _password)));
|
||||
}
|
||||
|
||||
|
||||
return httpRequest;
|
||||
}
|
||||
|
||||
protected virtual HttpRequestMessage CreateHttpRequest<T>(string path,
|
||||
Dictionary<string, object> queryPayload = null,
|
||||
T bodyPayload = default, HttpMethod method = null)
|
||||
{
|
||||
var request = CreateHttpRequest(path, queryPayload, method);
|
||||
if (typeof(T).IsPrimitive || !EqualityComparer<T>.Default.Equals(bodyPayload, default(T)))
|
||||
{
|
||||
request.Content = new StringContent(JsonConvert.SerializeObject(bodyPayload), Encoding.UTF8, "application/json");
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
private static void AppendPayloadToQuery(UriBuilder uri, Dictionary<string, object> payload)
|
||||
{
|
||||
if (uri.Query.Length > 1)
|
||||
uri.Query += "&";
|
||||
foreach (KeyValuePair<string, object> keyValuePair in payload)
|
||||
{
|
||||
UriBuilder uriBuilder = uri;
|
||||
if (keyValuePair.Value.GetType().GetInterfaces().Contains((typeof(IEnumerable))))
|
||||
{
|
||||
foreach (var item in (IEnumerable)keyValuePair.Value)
|
||||
{
|
||||
uriBuilder.Query = uriBuilder.Query + Uri.EscapeDataString(keyValuePair.Key) + "=" +
|
||||
Uri.EscapeDataString(item.ToString()) + "&";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
uriBuilder.Query = uriBuilder.Query + Uri.EscapeDataString(keyValuePair.Key) + "=" +
|
||||
Uri.EscapeDataString(keyValuePair.Value.ToString()) + "&";
|
||||
}
|
||||
}
|
||||
|
||||
uri.Query = uri.Query.Trim('&');
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using Newtonsoft.Json;
|
||||
using NBitcoin.JsonConverters;
|
||||
|
||||
namespace BTCPayServer.Client.JsonConverters
|
||||
{
|
||||
public class PermissionJsonConverter : JsonConverter
|
||||
{
|
||||
public override bool CanConvert(Type objectType)
|
||||
{
|
||||
return typeof(Permission).GetTypeInfo().IsAssignableFrom(objectType.GetTypeInfo());
|
||||
}
|
||||
|
||||
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
|
||||
{
|
||||
if (reader.TokenType == JsonToken.Null)
|
||||
return null;
|
||||
if (reader.TokenType != JsonToken.String)
|
||||
throw new JsonObjectException("Type 'Permission' is expected to be a 'String'", reader);
|
||||
if (reader.Value is String s && Permission.TryParse(s, out var permission))
|
||||
return permission;
|
||||
throw new JsonObjectException("Invalid 'Permission' String", reader);
|
||||
}
|
||||
|
||||
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
|
||||
{
|
||||
if (value is Permission v)
|
||||
writer.WriteValue(v.ToString());
|
||||
}
|
||||
}
|
||||
}
|
13
BTCPayServer.Client/Models/ApiKeyData.cs
Normal file
13
BTCPayServer.Client/Models/ApiKeyData.cs
Normal file
@ -0,0 +1,13 @@
|
||||
using BTCPayServer.Client.JsonConverters;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace BTCPayServer.Client.Models
|
||||
{
|
||||
public class ApiKeyData
|
||||
{
|
||||
public string ApiKey { get; set; }
|
||||
public string Label { get; set; }
|
||||
[JsonProperty(ItemConverterType = typeof(PermissionJsonConverter))]
|
||||
public Permission[] Permissions { get; set; }
|
||||
}
|
||||
}
|
38
BTCPayServer.Client/Models/ApplicationUserData.cs
Normal file
38
BTCPayServer.Client/Models/ApplicationUserData.cs
Normal file
@ -0,0 +1,38 @@
|
||||
namespace BTCPayServer.Client.Models
|
||||
{
|
||||
public class ApplicationUserData
|
||||
{
|
||||
/// <summary>
|
||||
/// the id of the user
|
||||
/// </summary>
|
||||
public string Id { get; set; }
|
||||
/// <summary>
|
||||
/// the email AND username of the user
|
||||
/// </summary>
|
||||
public string Email { get; set; }
|
||||
/// <summary>
|
||||
/// Whether the user has verified their email
|
||||
/// </summary>
|
||||
public bool EmailConfirmed { get; set; }
|
||||
/// <summary>
|
||||
/// whether the user needed to verify their email on account creation
|
||||
/// </summary>
|
||||
public bool RequiresEmailConfirmation { get; set; }
|
||||
}
|
||||
|
||||
public class CreateApplicationUserRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// the email AND username of the new user
|
||||
/// </summary>
|
||||
public string Email { get; set; }
|
||||
/// <summary>
|
||||
/// password of the new user
|
||||
/// </summary>
|
||||
public string Password { get; set; }
|
||||
/// <summary>
|
||||
/// Whether this user is an administrator. If left null and there are no admins in the system, the user will be created as an admin.
|
||||
/// </summary>
|
||||
public bool? IsAdministrator { get; set; }
|
||||
}
|
||||
}
|
15
BTCPayServer.Client/Models/CreateApiKeyRequest.cs
Normal file
15
BTCPayServer.Client/Models/CreateApiKeyRequest.cs
Normal file
@ -0,0 +1,15 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using BTCPayServer.Client.JsonConverters;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace BTCPayServer.Client.Models
|
||||
{
|
||||
public class CreateApiKeyRequest
|
||||
{
|
||||
public string Label { get; set; }
|
||||
[JsonProperty(ItemConverterType = typeof(PermissionJsonConverter))]
|
||||
public Permission[] Permissions { get; set; }
|
||||
}
|
||||
}
|
185
BTCPayServer.Client/Permissions.cs
Normal file
185
BTCPayServer.Client/Permissions.cs
Normal file
@ -0,0 +1,185 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace BTCPayServer.Client
|
||||
{
|
||||
public class Policies
|
||||
{
|
||||
public const string CanModifyServerSettings = "btcpay.server.canmodifyserversettings";
|
||||
public const string CanModifyStoreSettings = "btcpay.store.canmodifystoresettings";
|
||||
public const string CanViewStoreSettings = "btcpay.store.canviewstoresettings";
|
||||
public const string CanCreateInvoice = "btcpay.store.cancreateinvoice";
|
||||
public const string CanModifyProfile = "btcpay.user.canmodifyprofile";
|
||||
public const string CanViewProfile = "btcpay.user.canviewprofile";
|
||||
public const string CanCreateUser = "btcpay.server.cancreateuser";
|
||||
public const string Unrestricted = "unrestricted";
|
||||
public static IEnumerable<string> AllPolicies
|
||||
{
|
||||
get
|
||||
{
|
||||
yield return CanCreateInvoice;
|
||||
yield return CanModifyServerSettings;
|
||||
yield return CanModifyStoreSettings;
|
||||
yield return CanViewStoreSettings;
|
||||
yield return CanModifyProfile;
|
||||
yield return CanViewProfile;
|
||||
yield return CanCreateUser;
|
||||
yield return Unrestricted;
|
||||
}
|
||||
}
|
||||
public static bool IsValidPolicy(string policy)
|
||||
{
|
||||
return AllPolicies.Any(p => p.Equals(policy, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public static bool IsStorePolicy(string policy)
|
||||
{
|
||||
return policy.StartsWith("btcpay.store", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static bool IsServerPolicy(string policy)
|
||||
{
|
||||
return policy.StartsWith("btcpay.server", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
public class Permission
|
||||
{
|
||||
public static Permission Create(string policy, string storeId = null)
|
||||
{
|
||||
if (TryCreatePermission(policy, storeId, out var r))
|
||||
return r;
|
||||
throw new ArgumentException("Invalid Permission");
|
||||
}
|
||||
|
||||
public static bool TryCreatePermission(string policy, string storeId, out Permission permission)
|
||||
{
|
||||
permission = null;
|
||||
if (policy == null)
|
||||
throw new ArgumentNullException(nameof(policy));
|
||||
policy = policy.Trim().ToLowerInvariant();
|
||||
if (!Policies.IsValidPolicy(policy))
|
||||
return false;
|
||||
if (storeId != null && !Policies.IsStorePolicy(policy))
|
||||
return false;
|
||||
permission = new Permission(policy, storeId);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryParse(string str, out Permission permission)
|
||||
{
|
||||
permission = null;
|
||||
if (str == null)
|
||||
throw new ArgumentNullException(nameof(str));
|
||||
str = str.Trim();
|
||||
var separator = str.IndexOf(':');
|
||||
if (separator == -1)
|
||||
{
|
||||
str = str.ToLowerInvariant();
|
||||
if (!Policies.IsValidPolicy(str))
|
||||
return false;
|
||||
permission = new Permission(str, null);
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
var policy = str.Substring(0, separator).ToLowerInvariant();
|
||||
if (!Policies.IsValidPolicy(policy))
|
||||
return false;
|
||||
if (!Policies.IsStorePolicy(policy))
|
||||
return false;
|
||||
var storeId = str.Substring(separator + 1);
|
||||
if (storeId.Length == 0)
|
||||
return false;
|
||||
permission = new Permission(policy, storeId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
internal Permission(string policy, string storeId)
|
||||
{
|
||||
Policy = policy;
|
||||
StoreId = storeId;
|
||||
}
|
||||
|
||||
public bool Contains(Permission subpermission)
|
||||
{
|
||||
if (subpermission is null)
|
||||
throw new ArgumentNullException(nameof(subpermission));
|
||||
|
||||
if (!ContainsPolicy(subpermission.Policy))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (!Policies.IsStorePolicy(subpermission.Policy))
|
||||
return true;
|
||||
return StoreId == null || subpermission.StoreId == this.StoreId;
|
||||
}
|
||||
|
||||
public static IEnumerable<Permission> ToPermissions(string[] permissions)
|
||||
{
|
||||
if (permissions == null)
|
||||
throw new ArgumentNullException(nameof(permissions));
|
||||
foreach (var p in permissions)
|
||||
{
|
||||
if (TryParse(p, out var pp))
|
||||
yield return pp;
|
||||
}
|
||||
}
|
||||
|
||||
private bool ContainsPolicy(string subpolicy)
|
||||
{
|
||||
if (this.Policy == Policies.Unrestricted)
|
||||
return true;
|
||||
if (this.Policy == subpolicy)
|
||||
return true;
|
||||
if (subpolicy == Policies.CanViewStoreSettings && this.Policy == Policies.CanModifyStoreSettings)
|
||||
return true;
|
||||
if (subpolicy == Policies.CanCreateInvoice && this.Policy == Policies.CanModifyStoreSettings)
|
||||
return true;
|
||||
if (subpolicy == Policies.CanViewProfile && this.Policy == Policies.CanModifyProfile)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
public string StoreId { get; }
|
||||
public string Policy { get; }
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
if (StoreId != null)
|
||||
{
|
||||
return $"{Policy}:{StoreId}";
|
||||
}
|
||||
return Policy;
|
||||
}
|
||||
|
||||
public override bool Equals(object obj)
|
||||
{
|
||||
Permission item = obj as Permission;
|
||||
if (item == null)
|
||||
return false;
|
||||
return ToString().Equals(item.ToString());
|
||||
}
|
||||
public static bool operator ==(Permission a, Permission b)
|
||||
{
|
||||
if (System.Object.ReferenceEquals(a, b))
|
||||
return true;
|
||||
if (((object)a == null) || ((object)b == null))
|
||||
return false;
|
||||
return a.ToString() == b.ToString();
|
||||
}
|
||||
|
||||
public static bool operator !=(Permission a, Permission b)
|
||||
{
|
||||
return !(a == b);
|
||||
}
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
return ToString().GetHashCode();
|
||||
}
|
||||
}
|
||||
}
|
@ -24,6 +24,7 @@ namespace BTCPayServer
|
||||
DefaultSettings = BTCPayDefaultSettings.GetDefaultSettings(NetworkType),
|
||||
CoinType = NetworkType == NetworkType.Mainnet ? new KeyPath("0'") : new KeyPath("1'"),
|
||||
SupportRBF = true,
|
||||
SupportPayJoin = true,
|
||||
//https://github.com/spesmilo/electrum/blob/11733d6bc271646a00b69ff07657119598874da4/electrum/constants.py
|
||||
ElectrumMapping = NetworkType == NetworkType.Mainnet
|
||||
? new Dictionary<uint, DerivationType>()
|
||||
|
@ -28,7 +28,9 @@ namespace BTCPayServer
|
||||
CryptoImagePath = "imlegacy/groestlcoin.png",
|
||||
LightningImagePath = "imlegacy/groestlcoin-lightning.svg",
|
||||
DefaultSettings = BTCPayDefaultSettings.GetDefaultSettings(NetworkType),
|
||||
CoinType = NetworkType == NetworkType.Mainnet ? new KeyPath("17'") : new KeyPath("1'")
|
||||
CoinType = NetworkType == NetworkType.Mainnet ? new KeyPath("17'") : new KeyPath("1'"),
|
||||
SupportRBF = true,
|
||||
SupportPayJoin = true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -51,6 +51,28 @@ namespace BTCPayServer
|
||||
CoinType = NetworkType == NetworkType.Mainnet ? new KeyPath("1776'") : new KeyPath("1'"),
|
||||
SupportRBF = true
|
||||
});
|
||||
|
||||
Add(new ElementsBTCPayNetwork()
|
||||
{
|
||||
CryptoCode = "LCAD",
|
||||
NetworkCryptoCode = "LBTC",
|
||||
ShowSyncSummary = false,
|
||||
DefaultRateRules = new[]
|
||||
{
|
||||
"LCAD_CAD = 1",
|
||||
"LCAD_X = CAD_BTC * BTC_X",
|
||||
"LCAD_BTC = bylls(CAD_BTC)",
|
||||
},
|
||||
AssetId = new uint256("0e99c1a6da379d1f4151fb9df90449d40d0608f6cb33a5bcbfc8c265f42bab0a"),
|
||||
DisplayName = "Liquid CAD",
|
||||
BlockExplorerLink = NetworkType == NetworkType.Mainnet ? "https://blockstream.info/liquid/tx/{0}" : "https://blockstream.info/testnet/liquid/tx/{0}",
|
||||
NBXplorerNetwork = nbxplorerNetwork,
|
||||
UriScheme = "liquidnetwork",
|
||||
CryptoImagePath = "imlegacy/lcad.png",
|
||||
DefaultSettings = BTCPayDefaultSettings.GetDefaultSettings(NetworkType),
|
||||
CoinType = NetworkType == NetworkType.Mainnet ? new KeyPath("1776'") : new KeyPath("1'"),
|
||||
SupportRBF = true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -25,7 +25,11 @@ namespace BTCPayServer
|
||||
|
||||
public override string GenerateBIP21(string cryptoInfoAddress, Money cryptoInfoDue)
|
||||
{
|
||||
return $"{base.GenerateBIP21(cryptoInfoAddress, cryptoInfoDue)}&assetid={AssetId}";
|
||||
//precision 0: 10 = 0.00000010
|
||||
//precision 2: 10 = 0.00001000
|
||||
//precision 8: 10 = 10
|
||||
var money = new Money(cryptoInfoDue.ToDecimal(MoneyUnit.BTC) / decimal.Parse("1".PadRight(1 + 8 - Divisibility, '0')), MoneyUnit.BTC);
|
||||
return $"{base.GenerateBIP21(cryptoInfoAddress, money)}&assetid={AssetId}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -14,7 +14,6 @@ namespace BTCPayServer.Services.Altcoins.Monero.RPC.Models
|
||||
[JsonProperty("amount")] public long Amount { get; set; }
|
||||
[JsonProperty("confirmations")] public long Confirmations { get; set; }
|
||||
[JsonProperty("double_spend_seen")] public bool DoubleSpendSeen { get; set; }
|
||||
[JsonProperty("fee")] public long Fee { get; set; }
|
||||
[JsonProperty("height")] public long Height { get; set; }
|
||||
[JsonProperty("note")] public string Note { get; set; }
|
||||
[JsonProperty("payment_id")] public string PaymentId { get; set; }
|
||||
|
@ -18,7 +18,6 @@ namespace BTCPayServer.Services.Altcoins.Monero.RPC.Models
|
||||
[JsonProperty("amount")] public long Amount { get; set; }
|
||||
[JsonProperty("confirmations")] public long Confirmations { get; set; }
|
||||
[JsonProperty("double_spend_seen")] public bool DoubleSpendSeen { get; set; }
|
||||
[JsonProperty("fee")] public long Fee { get; set; }
|
||||
[JsonProperty("height")] public long Height { get; set; }
|
||||
[JsonProperty("note")] public string Note { get; set; }
|
||||
[JsonProperty("payment_id")] public string PaymentId { get; set; }
|
||||
|
@ -61,6 +61,8 @@ namespace BTCPayServer
|
||||
|
||||
public int MaxTrackedConfirmation { get; internal set; } = 6;
|
||||
public string UriScheme { get; internal set; }
|
||||
public bool SupportPayJoin { get; set; } = false;
|
||||
|
||||
public KeyPath GetRootKeyPath(DerivationType type)
|
||||
{
|
||||
KeyPath baseKey;
|
||||
|
@ -4,6 +4,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<PackageReference Include="NBXplorer.Client" Version="3.0.2" />
|
||||
<PackageReference Include="NBXplorer.Client" Version="3.0.9" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
@ -5,8 +5,8 @@
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="3.1.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="3.1.1" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="3.1.0" />
|
||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="3.1.0" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="3.1.1.2" />
|
||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="3.1.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="3.1.1" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
@ -22,18 +22,17 @@ namespace BTCPayServer.Data
|
||||
[MaxLength(50)] public string UserId { get; set; }
|
||||
|
||||
public APIKeyType Type { get; set; } = APIKeyType.Legacy;
|
||||
public string Permissions { get; set; }
|
||||
|
||||
public byte[] Blob { get; set; }
|
||||
public StoreData StoreData { get; set; }
|
||||
public ApplicationUser User { get; set; }
|
||||
public string Label { get; set; }
|
||||
public string[] GetPermissions() { return Permissions?.Split(';') ?? new string[0]; }
|
||||
}
|
||||
|
||||
public void SetPermissions(IEnumerable<string> permissions)
|
||||
{
|
||||
Permissions = string.Join(';',
|
||||
permissions?.Select(s => s.Replace(";", string.Empty)) ?? new string[0]);
|
||||
}
|
||||
public class APIKeyBlob
|
||||
{
|
||||
public string[] Permissions { get; set; }
|
||||
|
||||
}
|
||||
|
||||
public enum APIKeyType
|
||||
|
@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
@ -35,6 +35,9 @@ namespace BTCPayServer.Data
|
||||
get; set;
|
||||
}
|
||||
|
||||
public DbSet<PlannedTransaction> PlannedTransactions { get; set; }
|
||||
public DbSet<PayjoinLock> PayjoinLocks { get; set; }
|
||||
|
||||
public DbSet<AppData> Apps
|
||||
{
|
||||
get; set;
|
||||
@ -45,6 +48,8 @@ namespace BTCPayServer.Data
|
||||
get; set;
|
||||
}
|
||||
|
||||
public DbSet<OffchainTransactionData> OffchainTransactions { get; set; }
|
||||
|
||||
public DbSet<HistoricalAddressInvoiceData> HistoricalAddressInvoices
|
||||
{
|
||||
get; set;
|
||||
|
12
BTCPayServer.Data/Data/OffchainTransactionData.cs
Normal file
12
BTCPayServer.Data/Data/OffchainTransactionData.cs
Normal file
@ -0,0 +1,12 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace BTCPayServer.Data
|
||||
{
|
||||
public class OffchainTransactionData
|
||||
{
|
||||
[Key]
|
||||
[MaxLength(32*2)]
|
||||
public string Id { get; set; }
|
||||
public byte[] Blob { get; set; }
|
||||
}
|
||||
}
|
16
BTCPayServer.Data/Data/PayjoinLock.cs
Normal file
16
BTCPayServer.Data/Data/PayjoinLock.cs
Normal file
@ -0,0 +1,16 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace BTCPayServer.Data
|
||||
{
|
||||
/// <summary>
|
||||
/// We represent the locks of the PayjoinRepository
|
||||
/// with this table. (Both, our utxo we locked as part of a payjoin
|
||||
/// and the utxo of the payer which were used to pay us)
|
||||
/// </summary>
|
||||
public class PayjoinLock
|
||||
{
|
||||
[Key]
|
||||
[MaxLength(100)]
|
||||
public string Id { get; set; }
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
15
BTCPayServer.Data/Data/PlannedTransaction.cs
Normal file
15
BTCPayServer.Data/Data/PlannedTransaction.cs
Normal file
@ -0,0 +1,15 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace BTCPayServer.Data
|
||||
{
|
||||
public class PlannedTransaction
|
||||
{
|
||||
[Key]
|
||||
[MaxLength(100)]
|
||||
// Id in the format [cryptocode]-[txid]
|
||||
public string Id { get; set; }
|
||||
public DateTimeOffset BroadcastAt { get; set; }
|
||||
public byte[] Blob { get; set; }
|
||||
}
|
||||
}
|
42
BTCPayServer.Data/Migrations/20200402065615_AddApiKeyBlob.cs
Normal file
42
BTCPayServer.Data/Migrations/20200402065615_AddApiKeyBlob.cs
Normal file
@ -0,0 +1,42 @@
|
||||
using BTCPayServer.Data;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace BTCPayServer.Migrations
|
||||
{
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
[Migration("20200402065615_AddApiKeyBlob")]
|
||||
public partial class AddApiKeyBlob : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
if (this.SupportDropColumn(migrationBuilder.ActiveProvider))
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Permissions",
|
||||
table: "ApiKeys");
|
||||
}
|
||||
|
||||
migrationBuilder.AddColumn<byte[]>(
|
||||
name: "Blob",
|
||||
table: "ApiKeys",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
if (this.SupportDropColumn(migrationBuilder.ActiveProvider))
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Blob",
|
||||
table: "ApiKeys");
|
||||
}
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Permissions",
|
||||
table: "ApiKeys",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,59 @@
|
||||
using System;
|
||||
using BTCPayServer.Data;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace BTCPayServer.Migrations
|
||||
{
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
[Migration("20200413052418_PlannedTransactions")]
|
||||
public partial class PlannedTransactions : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PlannedTransactions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(maxLength: 100, nullable: false),
|
||||
BroadcastAt = table.Column<DateTimeOffset>(nullable: false),
|
||||
Blob = table.Column<byte[]>(nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PlannedTransactions", x => x.Id);
|
||||
});
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PayjoinLocks",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(maxLength: 100, nullable: false),
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PayjoinLocks", x => x.Id);
|
||||
});
|
||||
migrationBuilder.CreateTable(
|
||||
name: "OffchainTransactions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(maxLength: 64, nullable: false),
|
||||
Blob = table.Column<byte[]>(nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_OffchainTransactions", x => x.Id);
|
||||
});
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "PayjoinLocks");
|
||||
migrationBuilder.DropTable(
|
||||
name: "PlannedTransactions");
|
||||
migrationBuilder.DropTable(
|
||||
name: "OffchainTransactions");
|
||||
}
|
||||
}
|
||||
}
|
@ -22,10 +22,10 @@ namespace BTCPayServer.Migrations
|
||||
.HasColumnType("TEXT")
|
||||
.HasMaxLength(50);
|
||||
|
||||
b.Property<string>("Label")
|
||||
.HasColumnType("TEXT");
|
||||
b.Property<byte[]>("Blob")
|
||||
.HasColumnType("BLOB");
|
||||
|
||||
b.Property<string>("Permissions")
|
||||
b.Property<string>("Label")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("StoreId")
|
||||
@ -240,6 +240,20 @@ namespace BTCPayServer.Migrations
|
||||
b.ToTable("InvoiceEvents");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BTCPayServer.Data.OffchainTransactionData", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasMaxLength(64);
|
||||
|
||||
b.Property<byte[]>("Blob")
|
||||
.HasColumnType("BLOB");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("OffchainTransactions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BTCPayServer.Data.PairedSINData", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
@ -297,6 +311,17 @@ namespace BTCPayServer.Migrations
|
||||
b.ToTable("PairingCodes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BTCPayServer.Data.PayjoinLock", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasMaxLength(100);
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("PayjoinLocks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BTCPayServer.Data.PaymentData", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
@ -356,6 +381,23 @@ namespace BTCPayServer.Migrations
|
||||
b.ToTable("PendingInvoices");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BTCPayServer.Data.PlannedTransaction", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasMaxLength(100);
|
||||
|
||||
b.Property<byte[]>("Blob")
|
||||
.HasColumnType("BLOB");
|
||||
|
||||
b.Property<DateTimeOffset>("BroadcastAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("PlannedTransactions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BTCPayServer.Data.RefundAddressesData", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
|
@ -99,7 +99,7 @@ namespace BTCPayServer.Services.Rates
|
||||
{
|
||||
LastRequested = LastRequested
|
||||
};
|
||||
if (_Latest is LatestFetch fetch)
|
||||
if (_Latest is LatestFetch fetch && fetch.Latest is PairRate[])
|
||||
{
|
||||
state.LastUpdated = fetch.Updated;
|
||||
state.Rates = fetch.Latest
|
||||
|
35
BTCPayServer.Rating/Providers/BitflyerRateProvider.cs
Normal file
35
BTCPayServer.Rating/Providers/BitflyerRateProvider.cs
Normal file
@ -0,0 +1,35 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Rating;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace BTCPayServer.Services.Rates
|
||||
{
|
||||
public class BitflyerRateProvider : IRateProvider
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
public BitflyerRateProvider(HttpClient httpClient)
|
||||
{
|
||||
_httpClient = httpClient ?? new HttpClient();
|
||||
}
|
||||
|
||||
public async Task<PairRate[]> GetRatesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await _httpClient.GetAsync("https://api.bitflyer.jp/v1/ticker", cancellationToken);
|
||||
var jobj = await response.Content.ReadAsAsync<JObject>(cancellationToken);
|
||||
if (jobj.Property("error_message")?.Value?.Value<string>() is string err)
|
||||
{
|
||||
throw new Exception($"Error from bitflyer: {err}");
|
||||
}
|
||||
var bid = jobj.Property("best_bid").Value.Value<decimal>();
|
||||
var ask = jobj.Property("best_ask").Value.Value<decimal>();
|
||||
var rates = new PairRate[1];
|
||||
rates[0] = new PairRate(CurrencyPair.Parse(jobj.Property("product_code").Value.Value<string>()), new BidAsk(bid, ask));
|
||||
return rates;
|
||||
}
|
||||
}
|
||||
}
|
@ -77,6 +77,7 @@ namespace BTCPayServer.Services.Rates
|
||||
yield return new AvailableRateProvider("kraken", "Kraken", "https://api.kraken.com/0/public/Ticker?pair=ATOMETH,ATOMEUR,ATOMUSD,ATOMXBT,BATETH,BATEUR,BATUSD,BATXBT,BCHEUR,BCHUSD,BCHXBT,DAIEUR,DAIUSD,DAIUSDT,DASHEUR,DASHUSD,DASHXBT,EOSETH,EOSXBT,ETHCHF,ETHDAI,ETHUSDC,ETHUSDT,GNOETH,GNOXBT,ICXETH,ICXEUR,ICXUSD,ICXXBT,LINKETH,LINKEUR,LINKUSD,LINKXBT,LSKETH,LSKEUR,LSKUSD,LSKXBT,NANOETH,NANOEUR,NANOUSD,NANOXBT,OMGETH,OMGEUR,OMGUSD,OMGXBT,PAXGETH,PAXGEUR,PAXGUSD,PAXGXBT,SCETH,SCEUR,SCUSD,SCXBT,USDCEUR,USDCUSD,USDCUSDT,USDTCAD,USDTEUR,USDTGBP,USDTZUSD,WAVESETH,WAVESEUR,WAVESUSD,WAVESXBT,XBTCHF,XBTDAI,XBTUSDC,XBTUSDT,XDGEUR,XDGUSD,XETCXETH,XETCXXBT,XETCZEUR,XETCZUSD,XETHXXBT,XETHZCAD,XETHZEUR,XETHZGBP,XETHZJPY,XETHZUSD,XLTCXXBT,XLTCZEUR,XLTCZUSD,XMLNXETH,XMLNXXBT,XMLNZEUR,XMLNZUSD,XREPXETH,XREPXXBT,XREPZEUR,XXBTZCAD,XXBTZEUR,XXBTZGBP,XXBTZJPY,XXBTZUSD,XXDGXXBT,XXLMXXBT,XXMRXXBT,XXMRZEUR,XXMRZUSD,XXRPXXBT,XXRPZEUR,XXRPZUSD,XZECXXBT,XZECZEUR,XZECZUSD");
|
||||
yield return new AvailableRateProvider("bylls", "Bylls", "https://bylls.com/api/price?from_currency=BTC&to_currency=CAD");
|
||||
yield return new AvailableRateProvider("bitbank", "Bitbank", "https://public.bitbank.cc/prices");
|
||||
yield return new AvailableRateProvider("bitflyer", "Bitflyer", "https://api.bitflyer.com/v1/ticker");
|
||||
yield return new AvailableRateProvider("bitpay", "Bitpay", "https://bitpay.com/rates");
|
||||
|
||||
yield return new AvailableRateProvider("polispay", "PolisPay", "https://obol.polispay.com/complex/btc/polis");
|
||||
@ -100,6 +101,7 @@ namespace BTCPayServer.Services.Rates
|
||||
Providers.Add("bylls", new ByllsRateProvider(_httpClientFactory?.CreateClient("EXCHANGE_BYLLS")));
|
||||
Providers.Add("bitbank", new BitbankRateProvider(_httpClientFactory?.CreateClient("EXCHANGE_BITBANK")));
|
||||
Providers.Add("bitpay", new BitpayRateProvider(_httpClientFactory?.CreateClient("EXCHANGE_BITPAY")));
|
||||
Providers.Add("bitflyer", new BitflyerRateProvider(_httpClientFactory?.CreateClient("EXCHANGE_BITFLYER")));
|
||||
Providers.Add("polispay", new PolisRateProvider(_httpClientFactory?.CreateClient("EXCHANGE_POLIS")));
|
||||
|
||||
|
||||
|
@ -4,11 +4,12 @@ using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Security.APIKeys;
|
||||
using BTCPayServer.Security.GreenField;
|
||||
using BTCPayServer.Tests.Logging;
|
||||
using BTCPayServer.Views.Manage;
|
||||
using ExchangeSharp;
|
||||
using Newtonsoft.Json;
|
||||
using OpenQA.Selenium;
|
||||
using Xunit;
|
||||
@ -23,7 +24,7 @@ namespace BTCPayServer.Tests
|
||||
public const string TestApiPath = "api/test/apikey";
|
||||
public ApiKeysTests(ITestOutputHelper helper)
|
||||
{
|
||||
Logs.Tester = new XUnitLog(helper) {Name = "Tests"};
|
||||
Logs.Tester = new XUnitLog(helper) { Name = "Tests" };
|
||||
Logs.LogProvider = new XUnitLogProvider(helper);
|
||||
}
|
||||
|
||||
@ -42,61 +43,59 @@ namespace BTCPayServer.Tests
|
||||
|
||||
var user = tester.NewAccount();
|
||||
user.GrantAccess();
|
||||
|
||||
await user.CreateStoreAsync();
|
||||
await user.MakeAdmin(false);
|
||||
s.GoToLogin();
|
||||
s.Login(user.RegisterDetails.Email, user.RegisterDetails.Password);
|
||||
s.GoToProfile(ManageNavPages.APIKeys);
|
||||
s.Driver.FindElement(By.Id("AddApiKey")).Click();
|
||||
if (!user.IsAdmin)
|
||||
{
|
||||
//not an admin, so this permission should not show
|
||||
Assert.DoesNotContain("ServerManagementPermission", s.Driver.PageSource);
|
||||
await user.MakeAdmin();
|
||||
s.Logout();
|
||||
s.GoToLogin();
|
||||
s.Login(user.RegisterDetails.Email, user.RegisterDetails.Password);
|
||||
s.GoToProfile(ManageNavPages.APIKeys);
|
||||
s.Driver.FindElement(By.Id("AddApiKey")).Click();
|
||||
}
|
||||
|
||||
//not an admin, so this permission should not show
|
||||
Assert.DoesNotContain("btcpay.server.canmodifyserversettings", s.Driver.PageSource);
|
||||
await user.MakeAdmin();
|
||||
s.Logout();
|
||||
s.GoToLogin();
|
||||
s.Login(user.RegisterDetails.Email, user.RegisterDetails.Password);
|
||||
s.GoToProfile(ManageNavPages.APIKeys);
|
||||
s.Driver.FindElement(By.Id("AddApiKey")).Click();
|
||||
Assert.Contains("btcpay.server.canmodifyserversettings", s.Driver.PageSource);
|
||||
|
||||
//server management should show now
|
||||
s.SetCheckbox(s, "ServerManagementPermission", true);
|
||||
s.SetCheckbox(s, "StoreManagementPermission", true);
|
||||
s.SetCheckbox(s, "btcpay.server.canmodifyserversettings", true);
|
||||
s.SetCheckbox(s, "btcpay.store.canmodifystoresettings", true);
|
||||
s.SetCheckbox(s, "btcpay.user.canviewprofile", true);
|
||||
s.Driver.FindElement(By.Id("Generate")).Click();
|
||||
var superApiKey = s.AssertHappyMessage().FindElement(By.TagName("code")).Text;
|
||||
|
||||
//this api key has access to everything
|
||||
await TestApiAgainstAccessToken(superApiKey, tester, user, APIKeyConstants.Permissions.ServerManagement,
|
||||
APIKeyConstants.Permissions.StoreManagement);
|
||||
await TestApiAgainstAccessToken(superApiKey, tester, user, Policies.CanModifyServerSettings,Policies.CanModifyStoreSettings, Policies.CanViewProfile);
|
||||
|
||||
|
||||
s.Driver.FindElement(By.Id("AddApiKey")).Click();
|
||||
s.SetCheckbox(s, "ServerManagementPermission", true);
|
||||
s.SetCheckbox(s, "btcpay.server.canmodifyserversettings", true);
|
||||
s.Driver.FindElement(By.Id("Generate")).Click();
|
||||
var serverOnlyApiKey = s.AssertHappyMessage().FindElement(By.TagName("code")).Text;
|
||||
await TestApiAgainstAccessToken(serverOnlyApiKey, tester, user,
|
||||
APIKeyConstants.Permissions.ServerManagement);
|
||||
Policies.CanModifyServerSettings);
|
||||
|
||||
|
||||
s.Driver.FindElement(By.Id("AddApiKey")).Click();
|
||||
s.SetCheckbox(s, "StoreManagementPermission", true);
|
||||
s.SetCheckbox(s, "btcpay.store.canmodifystoresettings", true);
|
||||
s.Driver.FindElement(By.Id("Generate")).Click();
|
||||
var allStoreOnlyApiKey = s.AssertHappyMessage().FindElement(By.TagName("code")).Text;
|
||||
await TestApiAgainstAccessToken(allStoreOnlyApiKey, tester, user,
|
||||
APIKeyConstants.Permissions.StoreManagement);
|
||||
Policies.CanModifyStoreSettings);
|
||||
|
||||
s.Driver.FindElement(By.Id("AddApiKey")).Click();
|
||||
s.Driver.FindElement(By.CssSelector("button[value=change-store-mode]")).Click();
|
||||
s.Driver.FindElement(By.CssSelector("button[value='btcpay.store.canmodifystoresettings:change-store-mode']")).Click();
|
||||
//there should be a store already by default in the dropdown
|
||||
var dropdown = s.Driver.FindElement(By.Name("SpecificStores[0]"));
|
||||
var dropdown = s.Driver.FindElement(By.Name("PermissionValues[2].SpecificStores[0]"));
|
||||
var option = dropdown.FindElement(By.TagName("option"));
|
||||
var storeId = option.GetAttribute("value");
|
||||
option.Click();
|
||||
s.Driver.FindElement(By.Id("Generate")).Click();
|
||||
var selectiveStoreApiKey = s.AssertHappyMessage().FindElement(By.TagName("code")).Text;
|
||||
await TestApiAgainstAccessToken(selectiveStoreApiKey, tester, user,
|
||||
APIKeyConstants.Permissions.GetStorePermission(storeId));
|
||||
Permission.Create(Policies.CanModifyStoreSettings, storeId).ToString());
|
||||
|
||||
s.Driver.FindElement(By.Id("AddApiKey")).Click();
|
||||
s.Driver.FindElement(By.Id("Generate")).Click();
|
||||
@ -117,37 +116,14 @@ namespace BTCPayServer.Tests
|
||||
//permissions
|
||||
//strict
|
||||
//selectiveStores
|
||||
UriBuilder authorize = new UriBuilder(tester.PayTester.ServerUri);
|
||||
authorize.Path = "api-keys/authorize";
|
||||
|
||||
authorize.AppendPayloadToQuery(new Dictionary<string, object>()
|
||||
{
|
||||
{"redirect", "https://local.local/callback"},
|
||||
{"applicationName", "kukksappname"},
|
||||
{"strict", true},
|
||||
{"selectiveStores", false},
|
||||
{
|
||||
"permissions",
|
||||
new[]
|
||||
{
|
||||
APIKeyConstants.Permissions.StoreManagement,
|
||||
APIKeyConstants.Permissions.ServerManagement
|
||||
}
|
||||
},
|
||||
});
|
||||
var authUrl = authorize.ToString();
|
||||
var perms = new[]
|
||||
{
|
||||
APIKeyConstants.Permissions.StoreManagement, APIKeyConstants.Permissions.ServerManagement
|
||||
};
|
||||
authUrl = authUrl.Replace("permissions=System.String%5B%5D",
|
||||
string.Join("&", perms.Select(s1 => $"permissions={s1}")));
|
||||
var authUrl = BTCPayServerClient.GenerateAuthorizeUri(tester.PayTester.ServerUri,
|
||||
new[] { Policies.CanModifyStoreSettings, Policies.CanModifyServerSettings }).ToString();
|
||||
s.Driver.Navigate().GoToUrl(authUrl);
|
||||
s.Driver.PageSource.Contains("kukksappname");
|
||||
Assert.NotNull(s.Driver.FindElement(By.Id("StoreManagementPermission")).GetAttribute("readonly"));
|
||||
Assert.True(s.Driver.FindElement(By.Id("StoreManagementPermission")).Selected);
|
||||
Assert.NotNull(s.Driver.FindElement(By.Id("ServerManagementPermission")).GetAttribute("readonly"));
|
||||
Assert.True(s.Driver.FindElement(By.Id("ServerManagementPermission")).Selected);
|
||||
Assert.Equal("hidden", s.Driver.FindElement(By.Id("btcpay.store.canmodifystoresettings")).GetAttribute("type").ToLowerInvariant());
|
||||
Assert.Equal("true", s.Driver.FindElement(By.Id("btcpay.store.canmodifystoresettings")).GetAttribute("value").ToLowerInvariant());
|
||||
Assert.Equal("hidden", s.Driver.FindElement(By.Id("btcpay.server.canmodifyserversettings")).GetAttribute("type").ToLowerInvariant());
|
||||
Assert.Equal("true", s.Driver.FindElement(By.Id("btcpay.server.canmodifyserversettings")).GetAttribute("value").ToLowerInvariant());
|
||||
Assert.DoesNotContain("change-store-mode", s.Driver.PageSource);
|
||||
s.Driver.FindElement(By.Id("consent-yes")).Click();
|
||||
var url = s.Driver.Url;
|
||||
@ -155,109 +131,127 @@ namespace BTCPayServer.Tests
|
||||
.Select(s1 => new KeyValuePair<string, string>(s1.Split("=")[0], s1.Split("=")[1]));
|
||||
|
||||
var apiKeyRepo = s.Server.PayTester.GetService<APIKeyRepository>();
|
||||
|
||||
await TestApiAgainstAccessToken(results.Single(pair => pair.Key == "key").Value, tester, user,
|
||||
(await apiKeyRepo.GetKey(results.Single(pair => pair.Key == "key").Value)).GetPermissions());
|
||||
|
||||
authorize = new UriBuilder(tester.PayTester.ServerUri);
|
||||
authorize.Path = "api-keys/authorize";
|
||||
authorize.AppendPayloadToQuery(new Dictionary<string, object>()
|
||||
{
|
||||
{"strict", false},
|
||||
{"selectiveStores", true},
|
||||
{
|
||||
"permissions",
|
||||
new[]
|
||||
{
|
||||
APIKeyConstants.Permissions.StoreManagement,
|
||||
APIKeyConstants.Permissions.ServerManagement
|
||||
}
|
||||
}
|
||||
});
|
||||
authUrl = authorize.ToString();
|
||||
perms = new[]
|
||||
{
|
||||
APIKeyConstants.Permissions.StoreManagement, APIKeyConstants.Permissions.ServerManagement
|
||||
};
|
||||
authUrl = authUrl.Replace("permissions=System.String%5B%5D",
|
||||
string.Join("&", perms.Select(s1 => $"permissions={s1}")));
|
||||
await TestApiAgainstAccessToken(results.Single(pair => pair.Key == "key").Value, tester, user,
|
||||
(await apiKeyRepo.GetKey(results.Single(pair => pair.Key == "key").Value)).GetBlob().Permissions);
|
||||
|
||||
authUrl = BTCPayServerClient.GenerateAuthorizeUri(tester.PayTester.ServerUri,
|
||||
new[] { Policies.CanModifyStoreSettings, Policies.CanModifyServerSettings }, false, true).ToString();
|
||||
|
||||
s.Driver.Navigate().GoToUrl(authUrl);
|
||||
Assert.DoesNotContain("kukksappname", s.Driver.PageSource);
|
||||
|
||||
Assert.Null(s.Driver.FindElement(By.Id("StoreManagementPermission")).GetAttribute("readonly"));
|
||||
Assert.True(s.Driver.FindElement(By.Id("StoreManagementPermission")).Selected);
|
||||
Assert.Null(s.Driver.FindElement(By.Id("ServerManagementPermission")).GetAttribute("readonly"));
|
||||
Assert.True(s.Driver.FindElement(By.Id("ServerManagementPermission")).Selected);
|
||||
Assert.Equal("checkbox", s.Driver.FindElement(By.Id("btcpay.store.canmodifystoresettings")).GetAttribute("type").ToLowerInvariant());
|
||||
Assert.Equal("true", s.Driver.FindElement(By.Id("btcpay.store.canmodifystoresettings")).GetAttribute("value").ToLowerInvariant());
|
||||
Assert.Equal("checkbox", s.Driver.FindElement(By.Id("btcpay.server.canmodifyserversettings")).GetAttribute("type").ToLowerInvariant());
|
||||
Assert.Equal("true", s.Driver.FindElement(By.Id("btcpay.server.canmodifyserversettings")).GetAttribute("value").ToLowerInvariant());
|
||||
|
||||
s.SetCheckbox(s, "ServerManagementPermission", false);
|
||||
s.SetCheckbox(s, "btcpay.server.canmodifyserversettings", false);
|
||||
Assert.Contains("change-store-mode", s.Driver.PageSource);
|
||||
s.Driver.FindElement(By.Id("consent-yes")).Click();
|
||||
url = s.Driver.Url;
|
||||
results = url.Split("?").Last().Split("&")
|
||||
.Select(s1 => new KeyValuePair<string, string>(s1.Split("=")[0], s1.Split("=")[1]));
|
||||
|
||||
|
||||
await TestApiAgainstAccessToken(results.Single(pair => pair.Key == "key").Value, tester, user,
|
||||
(await apiKeyRepo.GetKey(results.Single(pair => pair.Key == "key").Value)).GetPermissions());
|
||||
|
||||
(await apiKeyRepo.GetKey(results.Single(pair => pair.Key == "key").Value)).GetBlob().Permissions);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
async Task TestApiAgainstAccessToken(string accessToken, ServerTester tester, TestAccount testAccount,
|
||||
params string[] permissions)
|
||||
params string[] expectedPermissionsArr)
|
||||
{
|
||||
var resultUser =
|
||||
await TestApiAgainstAccessToken<string>(accessToken, $"{TestApiPath}/me/id",
|
||||
tester.PayTester.HttpClient);
|
||||
Assert.Equal(testAccount.UserId, resultUser);
|
||||
var expectedPermissions = Permission.ToPermissions(expectedPermissionsArr).ToArray();
|
||||
expectedPermissions ??= new Permission[0];
|
||||
var apikeydata = await TestApiAgainstAccessToken<ApiKeyData>(accessToken, $"api/v1/api-keys/current", tester.PayTester.HttpClient);
|
||||
var permissions = apikeydata.Permissions;
|
||||
Assert.Equal(expectedPermissions.Length, permissions.Length);
|
||||
foreach (var expectPermission in expectedPermissions)
|
||||
{
|
||||
Assert.True(permissions.Any(p => p == expectPermission), $"Missing expected permission {expectPermission}");
|
||||
}
|
||||
|
||||
if (permissions.Contains(Permission.Create(Policies.CanViewProfile)))
|
||||
{
|
||||
var resultUser = await TestApiAgainstAccessToken<string>(accessToken, $"{TestApiPath}/me/id", tester.PayTester.HttpClient);
|
||||
Assert.Equal(testAccount.UserId, resultUser);
|
||||
}
|
||||
else
|
||||
{
|
||||
await Assert.ThrowsAnyAsync<HttpRequestException>(async () =>
|
||||
{
|
||||
await TestApiAgainstAccessToken<string>(accessToken, $"{TestApiPath}/me/id", tester.PayTester.HttpClient);
|
||||
});
|
||||
}
|
||||
//create a second user to see if any of its data gets messed upin our results.
|
||||
var secondUser = tester.NewAccount();
|
||||
secondUser.GrantAccess();
|
||||
|
||||
var selectiveStorePermissions = APIKeyConstants.Permissions.ExtractStorePermissionsIds(permissions);
|
||||
if (permissions.Contains(APIKeyConstants.Permissions.StoreManagement) || selectiveStorePermissions.Any())
|
||||
var canModifyAllStores = Permission.Create(Policies.CanModifyStoreSettings, null);
|
||||
var canModifyServer = Permission.Create(Policies.CanModifyServerSettings, null);
|
||||
var unrestricted = Permission.Create(Policies.Unrestricted, null);
|
||||
var selectiveStorePermissions = permissions.Where(p => p.StoreId != null && p.Policy == Policies.CanModifyStoreSettings);
|
||||
if (permissions.Contains(canModifyAllStores) || selectiveStorePermissions.Any())
|
||||
{
|
||||
var resultStores =
|
||||
await TestApiAgainstAccessToken<StoreData[]>(accessToken, $"{TestApiPath}/me/stores",
|
||||
tester.PayTester.HttpClient);
|
||||
|
||||
foreach (string selectiveStorePermission in selectiveStorePermissions)
|
||||
foreach (var selectiveStorePermission in selectiveStorePermissions)
|
||||
{
|
||||
Assert.True(await TestApiAgainstAccessToken<bool>(accessToken,
|
||||
$"{TestApiPath}/me/stores/{selectiveStorePermission}/can-edit",
|
||||
$"{TestApiPath}/me/stores/{selectiveStorePermission.StoreId}/can-edit",
|
||||
tester.PayTester.HttpClient));
|
||||
|
||||
Assert.Contains(resultStores,
|
||||
data => data.Id.Equals(selectiveStorePermission, StringComparison.InvariantCultureIgnoreCase));
|
||||
data => data.Id.Equals(selectiveStorePermission.StoreId, StringComparison.InvariantCultureIgnoreCase));
|
||||
}
|
||||
|
||||
if (permissions.Contains(APIKeyConstants.Permissions.StoreManagement))
|
||||
bool shouldBeAuthorized = false;
|
||||
if (permissions.Contains(canModifyAllStores) || selectiveStorePermissions.Contains(Permission.Create(Policies.CanViewStoreSettings, testAccount.StoreId)))
|
||||
{
|
||||
Assert.True(await TestApiAgainstAccessToken<bool>(accessToken,
|
||||
$"{TestApiPath}/me/stores/actions",
|
||||
$"{TestApiPath}/me/stores/{testAccount.StoreId}/can-view",
|
||||
tester.PayTester.HttpClient));
|
||||
Assert.Contains(resultStores,
|
||||
data => data.Id.Equals(testAccount.StoreId, StringComparison.InvariantCultureIgnoreCase));
|
||||
shouldBeAuthorized = true;
|
||||
}
|
||||
if (permissions.Contains(canModifyAllStores) || selectiveStorePermissions.Contains(Permission.Create(Policies.CanModifyStoreSettings, testAccount.StoreId)))
|
||||
{
|
||||
Assert.True(await TestApiAgainstAccessToken<bool>(accessToken,
|
||||
$"{TestApiPath}/me/stores/{testAccount.StoreId}/can-view",
|
||||
tester.PayTester.HttpClient));
|
||||
|
||||
Assert.True(await TestApiAgainstAccessToken<bool>(accessToken,
|
||||
$"{TestApiPath}/me/stores/{testAccount.StoreId}/can-edit",
|
||||
tester.PayTester.HttpClient));
|
||||
Assert.Contains(resultStores,
|
||||
data => data.Id.Equals(testAccount.StoreId, StringComparison.InvariantCultureIgnoreCase));
|
||||
shouldBeAuthorized = true;
|
||||
}
|
||||
else
|
||||
|
||||
if (!shouldBeAuthorized)
|
||||
{
|
||||
await Assert.ThrowsAnyAsync<HttpRequestException>(async () =>
|
||||
{
|
||||
await TestApiAgainstAccessToken<bool>(accessToken,
|
||||
$"{TestApiPath}/me/stores/actions",
|
||||
tester.PayTester.HttpClient);
|
||||
$"{TestApiPath}/me/stores/{testAccount.StoreId}/can-edit",
|
||||
tester.PayTester.HttpClient);
|
||||
});
|
||||
await Assert.ThrowsAnyAsync<HttpRequestException>(async () =>
|
||||
{
|
||||
await TestApiAgainstAccessToken<bool>(accessToken,
|
||||
$"{TestApiPath}/me/stores/{testAccount.StoreId}/can-view",
|
||||
tester.PayTester.HttpClient);
|
||||
});
|
||||
Assert.DoesNotContain(resultStores,
|
||||
data => data.Id.Equals(testAccount.StoreId, StringComparison.InvariantCultureIgnoreCase));
|
||||
}
|
||||
|
||||
Assert.DoesNotContain(resultStores,
|
||||
data => data.Id.Equals(secondUser.StoreId, StringComparison.InvariantCultureIgnoreCase));
|
||||
}
|
||||
else
|
||||
else if (!permissions.Contains(unrestricted))
|
||||
{
|
||||
|
||||
await Assert.ThrowsAnyAsync<HttpRequestException>(async () =>
|
||||
{
|
||||
await TestApiAgainstAccessToken<bool>(accessToken,
|
||||
@ -265,19 +259,42 @@ namespace BTCPayServer.Tests
|
||||
tester.PayTester.HttpClient);
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
await TestApiAgainstAccessToken<bool>(accessToken,
|
||||
$"{TestApiPath}/me/stores/{testAccount.StoreId}/can-edit",
|
||||
tester.PayTester.HttpClient);
|
||||
}
|
||||
|
||||
await Assert.ThrowsAnyAsync<HttpRequestException>(async () =>
|
||||
if (!permissions.Contains(unrestricted))
|
||||
{
|
||||
await Assert.ThrowsAnyAsync<HttpRequestException>(async () =>
|
||||
{
|
||||
await TestApiAgainstAccessToken<bool>(accessToken, $"{TestApiPath}/me/stores/{secondUser.StoreId}/can-edit",
|
||||
tester.PayTester.HttpClient);
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
await TestApiAgainstAccessToken<bool>(accessToken, $"{TestApiPath}/me/stores/{secondUser.StoreId}/can-edit",
|
||||
tester.PayTester.HttpClient);
|
||||
});
|
||||
}
|
||||
|
||||
if (permissions.Contains(APIKeyConstants.Permissions.ServerManagement))
|
||||
if (permissions.Contains(canModifyServer))
|
||||
{
|
||||
Assert.True(await TestApiAgainstAccessToken<bool>(accessToken,
|
||||
$"{TestApiPath}/me/is-admin",
|
||||
tester.PayTester.HttpClient));
|
||||
}
|
||||
else
|
||||
{
|
||||
await Assert.ThrowsAnyAsync<HttpRequestException>(async () =>
|
||||
{
|
||||
await TestApiAgainstAccessToken<bool>(accessToken,
|
||||
$"{TestApiPath}/me/is-admin",
|
||||
tester.PayTester.HttpClient);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<T> TestApiAgainstAccessToken<T>(string apikey, string url, HttpClient client)
|
||||
|
@ -23,9 +23,10 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.4.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.5.0" />
|
||||
<PackageReference Include="Newtonsoft.Json.Schema" Version="3.0.13" />
|
||||
<PackageReference Include="Selenium.WebDriver" Version="3.141.0" />
|
||||
<PackageReference Include="Selenium.WebDriver.ChromeDriver" Version="79.0.3945.3600" />
|
||||
<PackageReference Include="Selenium.WebDriver.ChromeDriver" Version="80.0.3987.10600" />
|
||||
<PackageReference Include="xunit" Version="2.4.1" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
|
@ -1,4 +1,6 @@
|
||||
using BTCPayServer.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using System.Linq;
|
||||
using BTCPayServer.HostedServices;
|
||||
using BTCPayServer.Hosting;
|
||||
@ -16,7 +18,6 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NBitcoin;
|
||||
using NBXplorer;
|
||||
@ -92,10 +93,12 @@ namespace BTCPayServer.Tests
|
||||
}
|
||||
|
||||
public bool MockRates { get; set; } = true;
|
||||
public string SocksEndpoint { get; set; }
|
||||
|
||||
public HashSet<string> Chains { get; set; } = new HashSet<string>(){"BTC"};
|
||||
public bool UseLightning { get; set; }
|
||||
|
||||
public bool AllowAdminRegistration { get; set; } = true;
|
||||
public bool DisableRegistration { get; set; } = false;
|
||||
public async Task StartAsync()
|
||||
{
|
||||
if (!Directory.Exists(_Directory))
|
||||
@ -137,9 +140,11 @@ namespace BTCPayServer.Tests
|
||||
config.AppendLine($"lbtc.explorer.url={LBTCNBXplorerUri.AbsoluteUri}");
|
||||
config.AppendLine($"lbtc.explorer.cookiefile=0");
|
||||
}
|
||||
config.AppendLine("allow-admin-registration=1");
|
||||
if (AllowAdminRegistration)
|
||||
config.AppendLine("allow-admin-registration=1");
|
||||
|
||||
config.AppendLine($"torrcfile={TestUtils.GetTestDataFullPath("Tor/torrc")}");
|
||||
config.AppendLine($"socksendpoint={SocksEndpoint}");
|
||||
config.AppendLine($"debuglog=debug.log");
|
||||
|
||||
|
||||
@ -161,7 +166,7 @@ namespace BTCPayServer.Tests
|
||||
HttpClient = new HttpClient();
|
||||
HttpClient.BaseAddress = ServerUri;
|
||||
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Development");
|
||||
var conf = new DefaultConfiguration() { Logger = Logs.LogProvider.CreateLogger("Console") }.CreateConfiguration(new[] { "--datadir", _Directory, "--conf", confPath, "--disable-registration", "false" });
|
||||
var conf = new DefaultConfiguration() { Logger = Logs.LogProvider.CreateLogger("Console") }.CreateConfiguration(new[] { "--datadir", _Directory, "--conf", confPath, "--disable-registration", DisableRegistration ? "true" : "false" });
|
||||
_Host = new WebHostBuilder()
|
||||
.UseConfiguration(conf)
|
||||
.UseContentRoot(FindBTCPayServerDirectory())
|
||||
@ -177,6 +182,10 @@ namespace BTCPayServer.Tests
|
||||
.AddProvider(Logs.LogProvider);
|
||||
});
|
||||
})
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
services.TryAddSingleton<IFeeProviderFactory>(new BTCPayServer.Services.Fees.FixedFeeProvider(new FeeRate(100L, 1)));
|
||||
})
|
||||
.UseKestrel()
|
||||
.UseStartup<Startup>()
|
||||
.Build();
|
||||
@ -221,6 +230,10 @@ namespace BTCPayServer.Tests
|
||||
var bitfinex = new MockRateProvider();
|
||||
bitfinex.ExchangeRates.Add(new PairRate(CurrencyPair.Parse("UST_BTC"), new BidAsk(0.000136m)));
|
||||
rateProvider.Providers.Add("bitfinex", bitfinex);
|
||||
|
||||
var bitpay = new MockRateProvider();
|
||||
bitpay.ExchangeRates.Add(new PairRate(CurrencyPair.Parse("ETB_BTC"), new BidAsk(0.1m)));
|
||||
rateProvider.Providers.Add("bitpay", bitpay);
|
||||
}
|
||||
|
||||
|
||||
@ -231,23 +244,13 @@ namespace BTCPayServer.Tests
|
||||
|
||||
private async Task WaitSiteIsOperational()
|
||||
{
|
||||
_ = HttpClient.GetAsync("/").ConfigureAwait(false);
|
||||
using (var cts = new CancellationTokenSource(20_000))
|
||||
{
|
||||
var synching = WaitIsFullySynched(cts.Token);
|
||||
var accessingHomepage = WaitCanAccessHomepage(cts.Token);
|
||||
await Task.WhenAll(synching, accessingHomepage).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WaitCanAccessHomepage(CancellationToken cancellationToken)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var resp = await HttpClient.GetAsync("/", cancellationToken).ConfigureAwait(false);
|
||||
if (resp.StatusCode == HttpStatusCode.OK)
|
||||
break;
|
||||
await Task.Delay(10, cancellationToken).ConfigureAwait(false);
|
||||
await Task.WhenAll(synching).ConfigureAwait(false);
|
||||
}
|
||||
// Opportunistic call to wake up view compilation in debug mode, we don't need to await.
|
||||
}
|
||||
|
||||
private async Task WaitIsFullySynched(CancellationToken cancellationToken)
|
||||
|
@ -205,7 +205,8 @@ namespace BTCPayServer.Tests
|
||||
IWebElement closebutton = null;
|
||||
TestUtils.Eventually(() =>
|
||||
{
|
||||
var iframe = s.Driver.SwitchTo().Frame("btcpay");
|
||||
var frameElement = s.Driver.FindElement(By.Name("btcpay"));
|
||||
var iframe = s.Driver.SwitchTo().Frame(frameElement);
|
||||
closebutton = iframe.FindElement(By.ClassName("close-action"));
|
||||
Assert.True(closebutton.Displayed);
|
||||
});
|
||||
|
@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Configuration.Memory;
|
||||
using NBitcoin;
|
||||
using NBitcoin.Payment;
|
||||
using NBitcoin.RPC;
|
||||
using NBitpayClient;
|
||||
using Xunit;
|
||||
@ -79,20 +80,28 @@ namespace BTCPayServer.Tests
|
||||
user.GrantAccess();
|
||||
user.RegisterDerivationScheme("LBTC");
|
||||
user.RegisterDerivationScheme("USDT");
|
||||
|
||||
user.RegisterDerivationScheme("ETB");
|
||||
await tester.LBTCExplorerNode.GenerateAsync(4);
|
||||
//no tether on our regtest, lets create it and set it
|
||||
var tether = tester.NetworkProvider.GetNetwork<ElementsBTCPayNetwork>("USDT");
|
||||
var lbtc = tester.NetworkProvider.GetNetwork<ElementsBTCPayNetwork>("LBTC");
|
||||
var etb = tester.NetworkProvider.GetNetwork<ElementsBTCPayNetwork>("ETB");
|
||||
var issueAssetResult = await tester.LBTCExplorerNode.SendCommandAsync("issueasset", 100000, 0);
|
||||
tether.AssetId = uint256.Parse(issueAssetResult.Result["asset"].ToString());
|
||||
((ElementsBTCPayNetwork)tester.PayTester.GetService<BTCPayWalletProvider>().GetWallet("USDT").Network)
|
||||
.AssetId = tether.AssetId;
|
||||
Logs.Tester.LogInformation($"Asset is {tether.AssetId}");
|
||||
Assert.Equal(tether.AssetId, tester.NetworkProvider.GetNetwork<ElementsBTCPayNetwork>("USDT").AssetId);
|
||||
Assert.Equal(tether.AssetId, ((ElementsBTCPayNetwork)tester.PayTester.GetService<BTCPayWalletProvider>().GetWallet("USDT").Network).AssetId);
|
||||
|
||||
var issueAssetResult2 = await tester.LBTCExplorerNode.SendCommandAsync("issueasset", 100000, 0);
|
||||
etb.AssetId = uint256.Parse(issueAssetResult2.Result["asset"].ToString());
|
||||
((ElementsBTCPayNetwork)tester.PayTester.GetService<BTCPayWalletProvider>().GetWallet("ETB").Network)
|
||||
.AssetId = etb.AssetId;
|
||||
|
||||
|
||||
//test: register 2 assets on the same elements network and make sure paying an invoice on one does not affect the other in any way
|
||||
var invoice = await user.BitPay.CreateInvoiceAsync(new Invoice(0.1m, "BTC"));
|
||||
Assert.Equal(2, invoice.SupportedTransactionCurrencies.Count);
|
||||
Assert.Equal(3, invoice.SupportedTransactionCurrencies.Count);
|
||||
var ci = invoice.CryptoInfo.Single(info => info.CryptoCode.Equals("LBTC"));
|
||||
//1 lbtc = 1 btc
|
||||
Assert.Equal(1, ci.Rate);
|
||||
@ -109,7 +118,7 @@ namespace BTCPayServer.Tests
|
||||
invoice = await user.BitPay.CreateInvoiceAsync(new Invoice(0.1m, "BTC"));
|
||||
|
||||
ci = invoice.CryptoInfo.Single(info => info.CryptoCode.Equals("USDT"));
|
||||
Assert.Equal(2, invoice.SupportedTransactionCurrencies.Count);
|
||||
Assert.Equal(3, invoice.SupportedTransactionCurrencies.Count);
|
||||
star = await tester.LBTCExplorerNode.SendCommandAsync("sendtoaddress", ci.Address, ci.Due, "", "", false, true,
|
||||
1, "UNSET", tether.AssetId);
|
||||
|
||||
@ -120,6 +129,14 @@ namespace BTCPayServer.Tests
|
||||
Assert.Single(localInvoice.CryptoInfo.Single(info => info.CryptoCode.Equals("USDT", StringComparison.InvariantCultureIgnoreCase)).Payments);
|
||||
});
|
||||
|
||||
//test precision based on https://github.com/ElementsProject/elements/issues/805#issuecomment-601277606
|
||||
var etbBip21 = new BitcoinUrlBuilder(invoice.CryptoInfo.Single(info => info.CryptoCode == "ETB").PaymentUrls.BIP21.Replace(etb.UriScheme, "bitcoin"), etb.NBitcoinNetwork);
|
||||
//precision = 2, 1ETB = 0.00000100
|
||||
Assert.Equal( 100,etbBip21.Amount.Satoshi);
|
||||
|
||||
var lbtcBip21 = new BitcoinUrlBuilder(invoice.CryptoInfo.Single(info => info.CryptoCode == "LBTC").PaymentUrls.BIP21.Replace(lbtc.UriScheme, "bitcoin"), lbtc.NBitcoinNetwork);
|
||||
//precision = 8, 0.1 = 0.1
|
||||
Assert.Equal( 0.1m,lbtcBip21.Amount.ToDecimal(MoneyUnit.BTC));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -88,6 +88,10 @@ namespace BTCPayServer.Tests
|
||||
if (!webElement.Displayed)
|
||||
return;
|
||||
}
|
||||
catch (NoSuchWindowException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (NoSuchElementException)
|
||||
{
|
||||
return;
|
||||
|
@ -1,16 +1,17 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Controllers;
|
||||
using BTCPayServer.Controllers.RestApi.ApiKeys;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Security.APIKeys;
|
||||
using BTCPayServer.Services;
|
||||
using BTCPayServer.Tests.Logging;
|
||||
using Microsoft.AspNet.SignalR.Client;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
using CreateApplicationUserRequest = BTCPayServer.Client.Models.CreateApplicationUserRequest;
|
||||
|
||||
namespace BTCPayServer.Tests
|
||||
{
|
||||
@ -22,11 +23,11 @@ namespace BTCPayServer.Tests
|
||||
|
||||
public GreenfieldAPITests(ITestOutputHelper helper)
|
||||
{
|
||||
Logs.Tester = new XUnitLog(helper) {Name = "Tests"};
|
||||
Logs.Tester = new XUnitLog(helper) { Name = "Tests" };
|
||||
Logs.LogProvider = new XUnitLogProvider(helper);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Fact(Timeout = TestTimeout)]
|
||||
[Trait("Integration", "Integration")]
|
||||
public async Task ApiKeysControllerTests()
|
||||
{
|
||||
@ -36,36 +37,196 @@ namespace BTCPayServer.Tests
|
||||
var user = tester.NewAccount();
|
||||
user.GrantAccess();
|
||||
await user.MakeAdmin();
|
||||
string apiKey = await GenerateAPIKey(tester, user);
|
||||
|
||||
var client = await user.CreateClient(Policies.CanViewProfile);
|
||||
var clientBasic = await user.CreateClient();
|
||||
//Get current api key
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, "api/v1/api-keys/current");
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("token", apiKey);
|
||||
var result = await tester.PayTester.HttpClient.SendAsync(request);
|
||||
Assert.True(result.IsSuccessStatusCode);
|
||||
var apiKeyData = JObject.Parse(await result.Content.ReadAsStringAsync()).ToObject<ApiKeyData>();
|
||||
var apiKeyData = await client.GetCurrentAPIKeyInfo();
|
||||
Assert.NotNull(apiKeyData);
|
||||
Assert.Equal(apiKey, apiKeyData.ApiKey);
|
||||
Assert.Equal(user.UserId, apiKeyData.UserId);
|
||||
Assert.Equal(2, apiKeyData.Permissions.Length);
|
||||
Assert.Equal(client.APIKey, apiKeyData.ApiKey);
|
||||
Assert.Single(apiKeyData.Permissions);
|
||||
|
||||
//a client using Basic Auth has no business here
|
||||
await AssertHttpError(401, async () => await clientBasic.GetCurrentAPIKeyInfo());
|
||||
|
||||
//revoke current api key
|
||||
await client.RevokeCurrentAPIKeyInfo();
|
||||
await AssertHttpError(401, async () => await client.GetCurrentAPIKeyInfo());
|
||||
//a client using Basic Auth has no business here
|
||||
await AssertHttpError(401, async () => await clientBasic.RevokeCurrentAPIKeyInfo());
|
||||
}
|
||||
}
|
||||
[Fact(Timeout = TestTimeout)]
|
||||
[Trait("Integration", "Integration")]
|
||||
public async Task CanCreateAndDeleteAPIKeyViaAPI()
|
||||
{
|
||||
using (var tester = ServerTester.Create())
|
||||
{
|
||||
await tester.StartAsync();
|
||||
var acc = tester.NewAccount();
|
||||
await acc.GrantAccessAsync();
|
||||
var unrestricted = await acc.CreateClient();
|
||||
var apiKey = await unrestricted.CreateAPIKey(new CreateApiKeyRequest()
|
||||
{
|
||||
Label = "Hello world",
|
||||
Permissions = new Permission[] { Permission.Create(Policies.CanViewProfile) }
|
||||
});
|
||||
Assert.Equal("Hello world", apiKey.Label);
|
||||
var p = Assert.Single(apiKey.Permissions);
|
||||
Assert.Equal(Policies.CanViewProfile, p.Policy);
|
||||
|
||||
var restricted = acc.CreateClientFromAPIKey(apiKey.ApiKey);
|
||||
await AssertHttpError(403, async () => await restricted.CreateAPIKey(new CreateApiKeyRequest()
|
||||
{
|
||||
Label = "Hello world2",
|
||||
Permissions = new Permission[] { Permission.Create(Policies.CanViewProfile) }
|
||||
}));
|
||||
|
||||
await unrestricted.RevokeAPIKey(apiKey.ApiKey);
|
||||
await AssertHttpError(404, async () => await unrestricted.RevokeAPIKey(apiKey.ApiKey));
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string> GenerateAPIKey(ServerTester tester, TestAccount user)
|
||||
[Fact(Timeout = TestTimeout)]
|
||||
[Trait("Integration", "Integration")]
|
||||
public async Task CanCreateUsersViaAPI()
|
||||
{
|
||||
var manageController = tester.PayTester.GetController<ManageController>(user.UserId, user.StoreId, user.IsAdmin);
|
||||
var x = Assert.IsType<RedirectToActionResult>(await manageController.AddApiKey(
|
||||
new ManageController.AddApiKeyViewModel()
|
||||
using (var tester = ServerTester.Create(newDb: true))
|
||||
{
|
||||
tester.PayTester.DisableRegistration = true;
|
||||
await tester.StartAsync();
|
||||
var unauthClient = new BTCPayServerClient(tester.PayTester.ServerUri);
|
||||
await AssertHttpError(400, async () => await unauthClient.CreateUser(new CreateApplicationUserRequest()));
|
||||
await AssertHttpError(400, async () => await unauthClient.CreateUser(new CreateApplicationUserRequest() { Email = "test@gmail.com" }));
|
||||
// Pass too simple
|
||||
await AssertHttpError(400, async () => await unauthClient.CreateUser(new CreateApplicationUserRequest() { Email = "test3@gmail.com", Password = "a" }));
|
||||
|
||||
// We have no admin, so it should work
|
||||
var user1 = await unauthClient.CreateUser(new CreateApplicationUserRequest() { Email = "test@gmail.com", Password = "abceudhqw" });
|
||||
// We have no admin, so it should work
|
||||
var user2 = await unauthClient.CreateUser(new CreateApplicationUserRequest() { Email = "test2@gmail.com", Password = "abceudhqw" });
|
||||
|
||||
// Duplicate email
|
||||
await AssertHttpError(400, async () => await unauthClient.CreateUser(new CreateApplicationUserRequest() { Email = "test2@gmail.com", Password = "abceudhqw" }));
|
||||
|
||||
// Let's make an admin
|
||||
var admin = await unauthClient.CreateUser(new CreateApplicationUserRequest() { Email = "admin@gmail.com", Password = "abceudhqw", IsAdministrator = true });
|
||||
|
||||
// Creating a new user without proper creds is now impossible (unauthorized)
|
||||
// Because if registration are locked and that an admin exists, we don't accept unauthenticated connection
|
||||
await AssertHttpError(401, async () => await unauthClient.CreateUser(new CreateApplicationUserRequest() { Email = "test3@gmail.com", Password = "afewfoiewiou" }));
|
||||
|
||||
|
||||
// But should be ok with subscriptions unlocked
|
||||
var settings = tester.PayTester.GetService<SettingsRepository>();
|
||||
await settings.UpdateSetting<PoliciesSettings>(new PoliciesSettings() { LockSubscription = false });
|
||||
await unauthClient.CreateUser(new CreateApplicationUserRequest() { Email = "test3@gmail.com", Password = "afewfoiewiou" });
|
||||
|
||||
// But it should be forbidden to create an admin without being authenticated
|
||||
await AssertHttpError(403, async () => await unauthClient.CreateUser(new CreateApplicationUserRequest() { Email = "admin2@gmail.com", Password = "afewfoiewiou", IsAdministrator = true }));
|
||||
await settings.UpdateSetting<PoliciesSettings>(new PoliciesSettings() { LockSubscription = true });
|
||||
|
||||
var adminAcc = tester.NewAccount();
|
||||
adminAcc.UserId = admin.Id;
|
||||
adminAcc.IsAdmin = true;
|
||||
var adminClient = await adminAcc.CreateClient(Policies.CanModifyProfile);
|
||||
|
||||
// We should be forbidden to create a new user without proper admin permissions
|
||||
await AssertHttpError(403, async () => await adminClient.CreateUser(new CreateApplicationUserRequest() { Email = "test4@gmail.com", Password = "afewfoiewiou" }));
|
||||
await AssertHttpError(403, async () => await adminClient.CreateUser(new CreateApplicationUserRequest() { Email = "test4@gmail.com", Password = "afewfoiewiou", IsAdministrator = true }));
|
||||
|
||||
// However, should be ok with the unrestricted permissions of an admin
|
||||
adminClient = await adminAcc.CreateClient(Policies.Unrestricted);
|
||||
await adminClient.CreateUser(new CreateApplicationUserRequest() { Email = "test4@gmail.com", Password = "afewfoiewiou" });
|
||||
// Even creating new admin should be ok
|
||||
await adminClient.CreateUser(new CreateApplicationUserRequest() { Email = "admin4@gmail.com", Password = "afewfoiewiou", IsAdministrator = true });
|
||||
|
||||
var user1Acc = tester.NewAccount();
|
||||
user1Acc.UserId = user1.Id;
|
||||
user1Acc.IsAdmin = false;
|
||||
var user1Client = await user1Acc.CreateClient(Policies.CanModifyServerSettings);
|
||||
|
||||
// User1 trying to get server management would still fail to create user
|
||||
await AssertHttpError(403, async () => await user1Client.CreateUser(new CreateApplicationUserRequest() { Email = "test8@gmail.com", Password = "afewfoiewiou" }));
|
||||
|
||||
// User1 should be able to create user if subscription unlocked
|
||||
await settings.UpdateSetting<PoliciesSettings>(new PoliciesSettings() { LockSubscription = false });
|
||||
await user1Client.CreateUser(new CreateApplicationUserRequest() { Email = "test8@gmail.com", Password = "afewfoiewiou" });
|
||||
|
||||
// But not an admin
|
||||
await AssertHttpError(403, async () => await user1Client.CreateUser(new CreateApplicationUserRequest() { Email = "admin8@gmail.com", Password = "afewfoiewiou", IsAdministrator = true }));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AssertHttpError(int code, Func<Task> act)
|
||||
{
|
||||
var ex = await Assert.ThrowsAsync<HttpRequestException>(act);
|
||||
Assert.Contains(code.ToString(), ex.Message);
|
||||
}
|
||||
|
||||
[Fact(Timeout = TestTimeout)]
|
||||
[Trait("Integration", "Integration")]
|
||||
public async Task UsersControllerTests()
|
||||
{
|
||||
using (var tester = ServerTester.Create(newDb: true))
|
||||
{
|
||||
tester.PayTester.DisableRegistration = true;
|
||||
await tester.StartAsync();
|
||||
var user = tester.NewAccount();
|
||||
user.GrantAccess();
|
||||
await user.MakeAdmin();
|
||||
var clientProfile = await user.CreateClient(Policies.CanModifyProfile);
|
||||
var clientServer = await user.CreateClient(Policies.CanCreateUser, Policies.CanViewProfile);
|
||||
var clientInsufficient = await user.CreateClient(Policies.CanModifyStoreSettings);
|
||||
var clientBasic = await user.CreateClient();
|
||||
|
||||
|
||||
var apiKeyProfileUserData = await clientProfile.GetCurrentUser();
|
||||
Assert.NotNull(apiKeyProfileUserData);
|
||||
Assert.Equal(apiKeyProfileUserData.Id, user.UserId);
|
||||
Assert.Equal(apiKeyProfileUserData.Email, user.RegisterDetails.Email);
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(async () => await clientInsufficient.GetCurrentUser());
|
||||
await clientServer.GetCurrentUser();
|
||||
await clientProfile.GetCurrentUser();
|
||||
await clientBasic.GetCurrentUser();
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(async () => await clientInsufficient.CreateUser(new CreateApplicationUserRequest()
|
||||
{
|
||||
ServerManagementPermission = true,
|
||||
StoreManagementPermission = true,
|
||||
StoreMode = ManageController.AddApiKeyViewModel.ApiKeyStoreMode.AllStores
|
||||
Email = $"{Guid.NewGuid()}@g.com",
|
||||
Password = Guid.NewGuid().ToString()
|
||||
}));
|
||||
var statusMessage = manageController.TempData.GetStatusMessageModel();
|
||||
Assert.NotNull(statusMessage);
|
||||
var apiKey = statusMessage.Html.Substring(statusMessage.Html.IndexOf("<code>") + 6);
|
||||
apiKey = apiKey.Substring(0, apiKey.IndexOf("</code>") );
|
||||
return apiKey;
|
||||
|
||||
var newUser = await clientServer.CreateUser(new CreateApplicationUserRequest()
|
||||
{
|
||||
Email = $"{Guid.NewGuid()}@g.com",
|
||||
Password = Guid.NewGuid().ToString()
|
||||
});
|
||||
Assert.NotNull(newUser);
|
||||
|
||||
var newUser2 = await clientBasic.CreateUser(new CreateApplicationUserRequest()
|
||||
{
|
||||
Email = $"{Guid.NewGuid()}@g.com",
|
||||
Password = Guid.NewGuid().ToString()
|
||||
});
|
||||
Assert.NotNull(newUser2);
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(async () => await clientServer.CreateUser(new CreateApplicationUserRequest()
|
||||
{
|
||||
Email = $"{Guid.NewGuid()}",
|
||||
Password = Guid.NewGuid().ToString()
|
||||
}));
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(async () => await clientServer.CreateUser(new CreateApplicationUserRequest()
|
||||
{
|
||||
Email = $"{Guid.NewGuid()}@g.com",
|
||||
}));
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(async () => await clientServer.CreateUser(new CreateApplicationUserRequest()
|
||||
{
|
||||
Password = Guid.NewGuid().ToString()
|
||||
}));
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
771
BTCPayServer.Tests/PayJoinTests.cs
Normal file
771
BTCPayServer.Tests/PayJoinTests.cs
Normal file
@ -0,0 +1,771 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Controllers;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Events;
|
||||
using BTCPayServer.Models;
|
||||
using BTCPayServer.Models.InvoicingModels;
|
||||
using BTCPayServer.Models.WalletViewModels;
|
||||
using BTCPayServer.Payments;
|
||||
using BTCPayServer.Payments.Bitcoin;
|
||||
using BTCPayServer.Payments.PayJoin;
|
||||
using BTCPayServer.Services;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using BTCPayServer.Services.Wallets;
|
||||
using BTCPayServer.Tests.Logging;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.VisualStudio.TestPlatform.CommunicationUtilities.ObjectModel;
|
||||
using NBitcoin;
|
||||
using NBitcoin.Altcoins;
|
||||
using NBitcoin.Payment;
|
||||
using NBitpayClient;
|
||||
using OpenQA.Selenium;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace BTCPayServer.Tests
|
||||
{
|
||||
public class PayJoinTests
|
||||
{
|
||||
public const int TestTimeout = 60_000;
|
||||
|
||||
public PayJoinTests(ITestOutputHelper helper)
|
||||
{
|
||||
Logs.Tester = new XUnitLog(helper) {Name = "Tests"};
|
||||
Logs.LogProvider = new XUnitLogProvider(helper);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Integration", "Integration")]
|
||||
public async Task CanUseTheDelayedBroadcaster()
|
||||
{
|
||||
using (var tester = ServerTester.Create())
|
||||
{
|
||||
await tester.StartAsync();
|
||||
var network = tester.NetworkProvider.GetNetwork<BTCPayNetwork>("BTC");
|
||||
var broadcaster = tester.PayTester.GetService<DelayedTransactionBroadcaster>();
|
||||
await broadcaster.Schedule(DateTimeOffset.UtcNow + TimeSpan.FromDays(500), RandomTransaction(network), network);
|
||||
var tx = RandomTransaction(network);
|
||||
await broadcaster.Schedule(DateTimeOffset.UtcNow - TimeSpan.FromDays(5), tx, network);
|
||||
// twice on same tx should be noop
|
||||
await broadcaster.Schedule(DateTimeOffset.UtcNow - TimeSpan.FromDays(5), tx, network);
|
||||
broadcaster.Disable();
|
||||
Assert.Equal(0, await broadcaster.ProcessAll());
|
||||
broadcaster.Enable();
|
||||
Assert.Equal(1, await broadcaster.ProcessAll());
|
||||
Assert.Equal(0, await broadcaster.ProcessAll());
|
||||
}
|
||||
}
|
||||
[Fact]
|
||||
[Trait("Integration", "Integration")]
|
||||
public async Task CanUsePayjoinRepository()
|
||||
{
|
||||
using (var tester = ServerTester.Create())
|
||||
{
|
||||
await tester.StartAsync();
|
||||
var network = tester.NetworkProvider.GetNetwork<BTCPayNetwork>("BTC");
|
||||
var repo = tester.PayTester.GetService<PayJoinRepository>();
|
||||
var outpoint = RandomOutpoint();
|
||||
|
||||
// Should not be locked
|
||||
Assert.False(await repo.TryUnlock(outpoint));
|
||||
|
||||
// Can lock input
|
||||
Assert.True(await repo.TryLockInputs(new [] { outpoint }));
|
||||
// Can't twice
|
||||
Assert.False(await repo.TryLockInputs(new [] { outpoint }));
|
||||
Assert.False(await repo.TryUnlock(outpoint));
|
||||
|
||||
// Lock and unlock outpoint utxo
|
||||
Assert.True(await repo.TryLock(outpoint));
|
||||
Assert.True(await repo.TryUnlock(outpoint));
|
||||
Assert.False(await repo.TryUnlock(outpoint));
|
||||
}
|
||||
}
|
||||
|
||||
private Transaction RandomTransaction(BTCPayNetwork network)
|
||||
{
|
||||
var tx = network.NBitcoinNetwork.CreateTransaction();
|
||||
tx.Inputs.Add(new OutPoint(RandomUtils.GetUInt256(), 0), Script.Empty);
|
||||
tx.Outputs.Add(Money.Coins(1.0m), new Key().ScriptPubKey);
|
||||
return tx;
|
||||
}
|
||||
|
||||
private OutPoint RandomOutpoint()
|
||||
{
|
||||
return new OutPoint(RandomUtils.GetUInt256(), 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Integration", "Integration")]
|
||||
public async Task CanOnlyUseCorrectAddressFormatsForPayjoin()
|
||||
{
|
||||
using (var tester = ServerTester.Create())
|
||||
{
|
||||
await tester.StartAsync();
|
||||
var broadcaster = tester.PayTester.GetService<DelayedTransactionBroadcaster>();
|
||||
var payjoinRepository = tester.PayTester.GetService<PayJoinRepository>();
|
||||
broadcaster.Disable();
|
||||
var network = tester.NetworkProvider.GetNetwork<BTCPayNetwork>("BTC");
|
||||
var btcPayWallet = tester.PayTester.GetService<BTCPayWalletProvider>().GetWallet(network);
|
||||
var cashCow = tester.ExplorerNode;
|
||||
cashCow.Generate(2); // get some money in case
|
||||
|
||||
var unsupportedFormats = Enum.GetValues(typeof(ScriptPubKeyType))
|
||||
.AssertType<ScriptPubKeyType[]>()
|
||||
.Where(type => !PayjoinClient.SupportedFormats.Contains(type));
|
||||
|
||||
|
||||
foreach (ScriptPubKeyType senderAddressType in Enum.GetValues(typeof(ScriptPubKeyType)))
|
||||
{
|
||||
var senderUser = tester.NewAccount();
|
||||
senderUser.GrantAccess(true);
|
||||
senderUser.RegisterDerivationScheme("BTC", senderAddressType);
|
||||
|
||||
foreach (ScriptPubKeyType receiverAddressType in Enum.GetValues(typeof(ScriptPubKeyType)))
|
||||
{
|
||||
var senderCoin = await senderUser.ReceiveUTXO(Money.Satoshis(100000), network);
|
||||
|
||||
Logs.Tester.LogInformation($"Testing payjoin with sender: {senderAddressType} receiver: {receiverAddressType}");
|
||||
var receiverUser = tester.NewAccount();
|
||||
receiverUser.GrantAccess(true);
|
||||
receiverUser.RegisterDerivationScheme("BTC", receiverAddressType, true);
|
||||
await receiverUser.EnablePayJoin();
|
||||
var receiverCoin = await receiverUser.ReceiveUTXO(Money.Satoshis(810), network);
|
||||
|
||||
var clientShouldError = unsupportedFormats.Contains(senderAddressType);
|
||||
var errorCode = ( unsupportedFormats.Contains( receiverAddressType) || receiverAddressType != senderAddressType)? "unsupported-inputs" : null;
|
||||
|
||||
var invoice = receiverUser.BitPay.CreateInvoice(new Invoice() {Price = 50000, Currency = "sats", FullNotifications = true});
|
||||
|
||||
var invoiceAddress = BitcoinAddress.Create(invoice.BitcoinAddress, cashCow.Network);
|
||||
var txBuilder = network.NBitcoinNetwork.CreateTransactionBuilder();
|
||||
|
||||
txBuilder.AddCoins(senderCoin);
|
||||
txBuilder.Send(invoiceAddress, invoice.BtcDue);
|
||||
txBuilder.SetChange(await senderUser.GetNewAddress(network));
|
||||
txBuilder.SendEstimatedFees(new FeeRate(50m));
|
||||
var psbt = txBuilder.BuildPSBT(false);
|
||||
psbt = await senderUser.Sign(psbt);
|
||||
var pj = await senderUser.SubmitPayjoin(invoice, psbt, errorCode, clientShouldError);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Selenium", "Selenium")]
|
||||
public async Task CanUsePayjoinViaUI()
|
||||
{
|
||||
using (var s = SeleniumTester.Create())
|
||||
{
|
||||
await s.StartAsync();
|
||||
var invoiceRepository = s.Server.PayTester.GetService<InvoiceRepository>();
|
||||
// var payjoinRepository = s.Server.PayTester.GetService<PayJoinRepository>();
|
||||
// var broadcaster = s.Server.PayTester.GetService<DelayedTransactionBroadcaster>();
|
||||
s.RegisterNewUser(true);
|
||||
var receiver = s.CreateNewStore();
|
||||
var receiverSeed = s.GenerateWallet("BTC", "", true, true);
|
||||
var receiverWalletId = new WalletId(receiver.storeId, "BTC");
|
||||
|
||||
//payjoin is not enabled by default.
|
||||
var invoiceId = s.CreateInvoice(receiver.storeId);
|
||||
s.GoToInvoiceCheckout(invoiceId);
|
||||
var bip21 = s.Driver.FindElement(By.ClassName("payment__details__instruction__open-wallet__btn"))
|
||||
.GetAttribute("href");
|
||||
Assert.DoesNotContain($"{PayjoinClient.BIP21EndpointKey}=", bip21);
|
||||
|
||||
s.GoToHome();
|
||||
s.GoToStore(receiver.storeId);
|
||||
//payjoin is not enabled by default.
|
||||
Assert.False(s.Driver.FindElement(By.Id("PayJoinEnabled")).Selected);
|
||||
s.SetCheckbox(s,"PayJoinEnabled", true);
|
||||
s.Driver.FindElement(By.Id("Save")).Click();
|
||||
Assert.True(s.Driver.FindElement(By.Id("PayJoinEnabled")).Selected);
|
||||
var sender = s.CreateNewStore();
|
||||
var senderSeed = s.GenerateWallet("BTC", "", true, true);
|
||||
var senderWalletId = new WalletId(sender.storeId, "BTC");
|
||||
await s.Server.ExplorerNode.GenerateAsync(1);
|
||||
await s.FundStoreWallet(senderWalletId);
|
||||
|
||||
invoiceId = s.CreateInvoice(receiver.storeId);
|
||||
s.GoToInvoiceCheckout(invoiceId);
|
||||
bip21 = s.Driver.FindElement(By.ClassName("payment__details__instruction__open-wallet__btn"))
|
||||
.GetAttribute("href");
|
||||
Assert.Contains($"{PayjoinClient.BIP21EndpointKey}=", bip21);
|
||||
|
||||
s.GoToWalletSend(senderWalletId);
|
||||
s.Driver.FindElement(By.Id("bip21parse")).Click();
|
||||
s.Driver.SwitchTo().Alert().SendKeys(bip21);
|
||||
s.Driver.SwitchTo().Alert().Accept();
|
||||
Assert.False(string.IsNullOrEmpty(s.Driver.FindElement(By.Id("PayJoinEndpointUrl")).GetAttribute("value")));
|
||||
s.Driver.ScrollTo(By.Id("SendMenu"));
|
||||
s.Driver.FindElement(By.Id("SendMenu")).ForceClick();
|
||||
s.Driver.FindElement(By.CssSelector("button[value=nbx-seed]")).Click();
|
||||
await s.Server.WaitForEvent<NewOnChainTransactionEvent>(() =>
|
||||
{
|
||||
s.Driver.FindElement(By.CssSelector("button[value=payjoin]")).ForceClick();
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
//no funds in receiver wallet to do payjoin
|
||||
s.AssertHappyMessage(StatusMessageModel.StatusSeverity.Warning);
|
||||
await TestUtils.EventuallyAsync(async () =>
|
||||
{
|
||||
var invoice = await s.Server.PayTester.GetService<InvoiceRepository>().GetInvoice(invoiceId);
|
||||
Assert.Equal(InvoiceStatus.Paid, invoice.Status);
|
||||
});
|
||||
|
||||
s.GoToInvoices();
|
||||
var paymentValueRowColumn = s.Driver.FindElement(By.Id($"invoice_{invoiceId}")).FindElement(By.ClassName("payment-value"));
|
||||
Assert.False(paymentValueRowColumn.Text.Contains("payjoin", StringComparison.InvariantCultureIgnoreCase));
|
||||
|
||||
//let's do it all again, except now the receiver has funds and is able to payjoin
|
||||
invoiceId = s.CreateInvoice(receiver.storeId);
|
||||
s.GoToInvoiceCheckout(invoiceId);
|
||||
bip21 = s.Driver.FindElement(By.ClassName("payment__details__instruction__open-wallet__btn"))
|
||||
.GetAttribute("href");
|
||||
Assert.Contains($"{PayjoinClient.BIP21EndpointKey}", bip21);
|
||||
|
||||
s.GoToWalletSend(senderWalletId);
|
||||
s.Driver.FindElement(By.Id("bip21parse")).Click();
|
||||
s.Driver.SwitchTo().Alert().SendKeys(bip21);
|
||||
s.Driver.SwitchTo().Alert().Accept();
|
||||
Assert.False(string.IsNullOrEmpty(s.Driver.FindElement(By.Id("PayJoinEndpointUrl")).GetAttribute("value")));
|
||||
s.Driver.FindElement(By.Id("FeeSatoshiPerByte")).Clear();
|
||||
s.Driver.FindElement(By.Id("FeeSatoshiPerByte")).SendKeys("1");
|
||||
s.Driver.ScrollTo(By.Id("SendMenu"));
|
||||
s.Driver.FindElement(By.Id("SendMenu")).ForceClick();
|
||||
s.Driver.FindElement(By.CssSelector("button[value=nbx-seed]")).Click();
|
||||
await s.Server.WaitForEvent<NewOnChainTransactionEvent>(() =>
|
||||
{
|
||||
s.Driver.FindElement(By.CssSelector("button[value=payjoin]")).ForceClick();
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
s.AssertHappyMessage(StatusMessageModel.StatusSeverity.Success);
|
||||
await TestUtils.EventuallyAsync(async () =>
|
||||
{
|
||||
var invoice = await invoiceRepository.GetInvoice(invoiceId);
|
||||
var payments = invoice.GetPayments();
|
||||
Assert.Equal(2, payments.Count);
|
||||
var originalPayment = payments[0];
|
||||
var coinjoinPayment = payments[1];
|
||||
Assert.Equal(-1, ((BitcoinLikePaymentData)originalPayment.GetCryptoPaymentData()).ConfirmationCount);
|
||||
Assert.Equal(0, ((BitcoinLikePaymentData)coinjoinPayment.GetCryptoPaymentData()).ConfirmationCount);
|
||||
Assert.False(originalPayment.Accounted);
|
||||
Assert.True(coinjoinPayment.Accounted);
|
||||
Assert.Equal(((BitcoinLikePaymentData)originalPayment.GetCryptoPaymentData()).Value,
|
||||
((BitcoinLikePaymentData)coinjoinPayment.GetCryptoPaymentData()).Value);
|
||||
Assert.Equal(originalPayment.GetCryptoPaymentData()
|
||||
.AssertType<BitcoinLikePaymentData>()
|
||||
.Value,
|
||||
coinjoinPayment.GetCryptoPaymentData()
|
||||
.AssertType<BitcoinLikePaymentData>()
|
||||
.Value);
|
||||
});
|
||||
|
||||
await TestUtils.EventuallyAsync(async () =>
|
||||
{
|
||||
var invoice = await s.Server.PayTester.GetService<InvoiceRepository>().GetInvoice(invoiceId);
|
||||
var dto = invoice.EntityToDTO();
|
||||
Assert.Equal(InvoiceStatus.Paid, invoice.Status);
|
||||
});
|
||||
s.GoToInvoices();
|
||||
paymentValueRowColumn = s.Driver.FindElement(By.Id($"invoice_{invoiceId}")).FindElement(By.ClassName("payment-value"));
|
||||
Assert.False(paymentValueRowColumn.Text.Contains("payjoin", StringComparison.InvariantCultureIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Integration", "Integration")]
|
||||
public async Task CanUsePayjoinFeeCornerCase()
|
||||
{
|
||||
using (var tester = ServerTester.Create())
|
||||
{
|
||||
await tester.StartAsync();
|
||||
var broadcaster = tester.PayTester.GetService<DelayedTransactionBroadcaster>();
|
||||
var payjoinRepository = tester.PayTester.GetService<PayJoinRepository>();
|
||||
broadcaster.Disable();
|
||||
var network = tester.NetworkProvider.GetNetwork<BTCPayNetwork>("BTC");
|
||||
var btcPayWallet = tester.PayTester.GetService<BTCPayWalletProvider>().GetWallet(network);
|
||||
var cashCow = tester.ExplorerNode;
|
||||
cashCow.Generate(2); // get some money in case
|
||||
|
||||
var senderUser = tester.NewAccount();
|
||||
senderUser.GrantAccess(true);
|
||||
senderUser.RegisterDerivationScheme("BTC", ScriptPubKeyType.Segwit);
|
||||
|
||||
var receiverUser = tester.NewAccount();
|
||||
receiverUser.GrantAccess(true);
|
||||
receiverUser.RegisterDerivationScheme("BTC", ScriptPubKeyType.Segwit, true);
|
||||
await receiverUser.EnablePayJoin();
|
||||
var receiverCoin = await receiverUser.ReceiveUTXO(Money.Satoshis(810), network);
|
||||
string lastInvoiceId = null;
|
||||
|
||||
var vector = (SpentCoin: Money.Satoshis(810), InvoiceAmount: Money.Satoshis(700), Paid: Money.Satoshis(700), Fee: Money.Satoshis(110), InvoicePaid: true, ExpectedError: "not-enough-money");
|
||||
async Task<PSBT> RunVector(bool skipLockedCheck = false)
|
||||
{
|
||||
var coin = await senderUser.ReceiveUTXO(vector.SpentCoin, network);
|
||||
var invoice = receiverUser.BitPay.CreateInvoice(new Invoice() {Price = vector.InvoiceAmount.ToDecimal(MoneyUnit.BTC), Currency = "BTC", FullNotifications = true});
|
||||
lastInvoiceId = invoice.Id;
|
||||
var invoiceAddress = BitcoinAddress.Create(invoice.BitcoinAddress, cashCow.Network);
|
||||
var txBuilder = network.NBitcoinNetwork.CreateTransactionBuilder();
|
||||
txBuilder.AddCoins(coin);
|
||||
txBuilder.Send(invoiceAddress, vector.Paid);
|
||||
txBuilder.SendFees(vector.Fee);
|
||||
txBuilder.SetChange(await senderUser.GetNewAddress(network));
|
||||
var psbt = txBuilder.BuildPSBT(false);
|
||||
psbt = await senderUser.Sign(psbt);
|
||||
var pj = await senderUser.SubmitPayjoin(invoice, psbt, vector.ExpectedError);
|
||||
if (vector.ExpectedError is null)
|
||||
{
|
||||
Assert.Contains(pj.Inputs, o => o.PrevOut == receiverCoin.Outpoint);
|
||||
if (!skipLockedCheck)
|
||||
Assert.True(await payjoinRepository.TryUnlock(receiverCoin.Outpoint));
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Null(pj);
|
||||
if (!skipLockedCheck)
|
||||
Assert.False(await payjoinRepository.TryUnlock(receiverCoin.Outpoint));
|
||||
}
|
||||
|
||||
if (vector.InvoicePaid)
|
||||
{
|
||||
await TestUtils.EventuallyAsync(async () =>
|
||||
{
|
||||
invoice = await receiverUser.BitPay.GetInvoiceAsync(invoice.Id);
|
||||
Assert.Equal("paid", invoice.Status);
|
||||
});
|
||||
}
|
||||
return pj;
|
||||
}
|
||||
|
||||
async Task LockAllButReceiverCoin()
|
||||
{
|
||||
var coins = await btcPayWallet.GetUnspentCoins(receiverUser.DerivationScheme);
|
||||
foreach (var coin in coins)
|
||||
{
|
||||
if (coin.OutPoint != receiverCoin.Outpoint)
|
||||
await payjoinRepository.TryLock(coin.OutPoint);
|
||||
else
|
||||
await payjoinRepository.TryUnlock(coin.OutPoint);
|
||||
}
|
||||
}
|
||||
|
||||
Logs.Tester.LogInformation("Here we send exactly the right amount. This should fails as\n" +
|
||||
"there is not enough to pay the additional payjoin input. (going below the min relay fee" +
|
||||
"However, the original tx has been broadcasted!");
|
||||
vector = (SpentCoin: Money.Satoshis(810), InvoiceAmount: Money.Satoshis(700), Paid: Money.Satoshis(700), Fee: Money.Satoshis(110), InvoicePaid: true, ExpectedError: "not-enough-money");
|
||||
await RunVector();
|
||||
await LockAllButReceiverCoin();
|
||||
|
||||
Logs.Tester.LogInformation("We don't pay enough");
|
||||
vector = (SpentCoin: Money.Satoshis(810), InvoiceAmount: Money.Satoshis(700), Paid: Money.Satoshis(690), Fee: Money.Satoshis(110), InvoicePaid: false, ExpectedError: "invoice-not-fully-paid");
|
||||
await RunVector();
|
||||
|
||||
Logs.Tester.LogInformation("We pay correctly");
|
||||
vector = (SpentCoin: Money.Satoshis(810), InvoiceAmount: Money.Satoshis(500), Paid: Money.Satoshis(500), Fee: Money.Satoshis(110), InvoicePaid: true, ExpectedError: null as string);
|
||||
await RunVector();
|
||||
await LockAllButReceiverCoin();
|
||||
|
||||
Logs.Tester.LogInformation("We pay a little bit more the invoice with enough fees to support additional input\n" +
|
||||
"The receiver should have added a fake output");
|
||||
vector = (SpentCoin: Money.Satoshis(910), InvoiceAmount: Money.Satoshis(500), Paid: Money.Satoshis(700), Fee: Money.Satoshis(110), InvoicePaid: true, ExpectedError: null as string);
|
||||
var proposedPSBT = await RunVector();
|
||||
Assert.Equal(2, proposedPSBT.Outputs.Count);
|
||||
await LockAllButReceiverCoin();
|
||||
|
||||
Logs.Tester.LogInformation("We pay correctly, but no utxo\n" +
|
||||
"However, this has the side effect of having the receiver broadcasting the original tx");
|
||||
await payjoinRepository.TryLock(receiverCoin.Outpoint);
|
||||
vector = (SpentCoin: Money.Satoshis(810), InvoiceAmount: Money.Satoshis(500), Paid: Money.Satoshis(500), Fee: Money.Satoshis(110), InvoicePaid: true, ExpectedError: "out-of-utxos");
|
||||
await RunVector(true);
|
||||
await LockAllButReceiverCoin();
|
||||
|
||||
var originalSenderUser = senderUser;
|
||||
retry:
|
||||
// Additional fee is 96 , minrelaytx is 294
|
||||
// We pay correctly, fees partially taken from what is overpaid
|
||||
// We paid 510, the receiver pay 10 sat
|
||||
// The send pay remaining 86 sat from his pocket
|
||||
// So total paid by sender should be 86 + 510 + 200 so we should get 1090 - (86 + 510 + 200) == 294 back)
|
||||
Logs.Tester.LogInformation($"Check if we can take fee on overpaid utxo{(senderUser == receiverUser ? " (to self)" : "")}");
|
||||
vector = (SpentCoin: Money.Satoshis(1090), InvoiceAmount: Money.Satoshis(500), Paid: Money.Satoshis(510), Fee: Money.Satoshis(200), InvoicePaid: true, ExpectedError: null as string);
|
||||
proposedPSBT = await RunVector();
|
||||
Assert.Equal(2, proposedPSBT.Outputs.Count);
|
||||
Assert.Contains(proposedPSBT.Outputs, o => o.Value == Money.Satoshis(500) + receiverCoin.Amount);
|
||||
Assert.Contains(proposedPSBT.Outputs, o => o.Value == Money.Satoshis(294));
|
||||
proposedPSBT = await senderUser.Sign(proposedPSBT);
|
||||
proposedPSBT = proposedPSBT.Finalize();
|
||||
var explorerClient = tester.PayTester.GetService<ExplorerClientProvider>().GetExplorerClient(proposedPSBT.Network.NetworkSet.CryptoCode);
|
||||
var result = await explorerClient.BroadcastAsync(proposedPSBT.ExtractTransaction());
|
||||
Assert.True(result.Success);
|
||||
Logs.Tester.LogInformation($"We broadcasted the payjoin {proposedPSBT.ExtractTransaction().GetHash()}");
|
||||
Logs.Tester.LogInformation($"Let's make sure that the coinjoin is not over paying, since the 10 overpaid sats have gone to fee");
|
||||
await TestUtils.EventuallyAsync(async () =>
|
||||
{
|
||||
var invoice = await tester.PayTester.GetService<InvoiceRepository>().GetInvoice(lastInvoiceId);
|
||||
Assert.Equal(InvoiceStatus.Paid, invoice.Status);
|
||||
Assert.Equal(InvoiceExceptionStatus.None, invoice.ExceptionStatus);
|
||||
var coins = await btcPayWallet.GetUnspentCoins(receiverUser.DerivationScheme);
|
||||
foreach (var coin in coins)
|
||||
await payjoinRepository.TryLock(coin.OutPoint);
|
||||
});
|
||||
tester.ExplorerNode.Generate(1);
|
||||
receiverCoin = await receiverUser.ReceiveUTXO(Money.Satoshis(810), network);
|
||||
await LockAllButReceiverCoin();
|
||||
if (senderUser != receiverUser)
|
||||
{
|
||||
Logs.Tester.LogInformation("Let's do the same, this time paying to ourselves");
|
||||
senderUser = receiverUser;
|
||||
goto retry;
|
||||
}
|
||||
else
|
||||
{
|
||||
senderUser = originalSenderUser;
|
||||
}
|
||||
|
||||
|
||||
// Same as above. Except the sender send one satoshi less, so the change
|
||||
// output would get below dust and would be removed completely.
|
||||
// So we remove as much fee as we can, and still accept the transaction because it is above minrelay fee
|
||||
vector = (SpentCoin: Money.Satoshis(1089), InvoiceAmount: Money.Satoshis(500), Paid: Money.Satoshis(510), Fee: Money.Satoshis(200), InvoicePaid: true, ExpectedError: null as string);
|
||||
proposedPSBT = await RunVector();
|
||||
Assert.Equal(2, proposedPSBT.Outputs.Count);
|
||||
// We should have our payment
|
||||
Assert.Contains(proposedPSBT.Outputs, output => output.Value == Money.Satoshis(500) + receiverCoin.Amount);
|
||||
// Plus our other change output with value just at dust level
|
||||
Assert.Contains(proposedPSBT.Outputs, output => output.Value == Money.Satoshis(294));
|
||||
proposedPSBT = await senderUser.Sign(proposedPSBT);
|
||||
proposedPSBT = proposedPSBT.Finalize();
|
||||
explorerClient = tester.PayTester.GetService<ExplorerClientProvider>().GetExplorerClient(proposedPSBT.Network.NetworkSet.CryptoCode);
|
||||
result = await explorerClient.BroadcastAsync(proposedPSBT.ExtractTransaction(), true);
|
||||
Assert.True(result.Success);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(Timeout = TestTimeout)]
|
||||
[Trait("Integration", "Integration")]
|
||||
public async Task CanUsePayjoin()
|
||||
{
|
||||
using (var tester = ServerTester.Create())
|
||||
{
|
||||
await tester.StartAsync();
|
||||
|
||||
////var payJoinStateProvider = tester.PayTester.GetService<PayJoinStateProvider>();
|
||||
var btcPayNetwork = tester.NetworkProvider.GetNetwork<BTCPayNetwork>("BTC");
|
||||
var btcPayWallet = tester.PayTester.GetService<BTCPayWalletProvider>().GetWallet(btcPayNetwork);
|
||||
var cashCow = tester.ExplorerNode;
|
||||
cashCow.Generate(2); // get some money in case
|
||||
|
||||
var senderUser = tester.NewAccount();
|
||||
senderUser.GrantAccess(true);
|
||||
senderUser.RegisterDerivationScheme("BTC", ScriptPubKeyType.Segwit, true);
|
||||
|
||||
var invoice = senderUser.BitPay.CreateInvoice(
|
||||
new Invoice() {Price = 100, Currency = "USD", FullNotifications = true});
|
||||
//payjoin is not enabled by default.
|
||||
Assert.DoesNotContain($"{PayjoinClient.BIP21EndpointKey}", invoice.CryptoInfo.First().PaymentUrls.BIP21);
|
||||
cashCow.SendToAddress(BitcoinAddress.Create(invoice.BitcoinAddress, cashCow.Network),
|
||||
Money.Coins(0.06m));
|
||||
|
||||
var receiverUser = tester.NewAccount();
|
||||
receiverUser.GrantAccess(true);
|
||||
receiverUser.RegisterDerivationScheme("BTC", ScriptPubKeyType.Segwit, true);
|
||||
|
||||
await receiverUser.EnablePayJoin();
|
||||
// payjoin is enabled, with a segwit wallet, and the keys are available in nbxplorer
|
||||
invoice = receiverUser.BitPay.CreateInvoice(
|
||||
new Invoice() {Price = 0.02m, Currency = "BTC", FullNotifications = true});
|
||||
cashCow.SendToAddress(BitcoinAddress.Create(invoice.BitcoinAddress, cashCow.Network),
|
||||
Money.Coins(0.06m));
|
||||
var receiverWalletId = new WalletId(receiverUser.StoreId, "BTC");
|
||||
|
||||
//give the cow some cash
|
||||
await cashCow.GenerateAsync(1);
|
||||
//let's get some more utxos first
|
||||
await receiverUser.ReceiveUTXO(Money.Coins(0.011m), btcPayNetwork);
|
||||
await receiverUser.ReceiveUTXO(Money.Coins(0.012m), btcPayNetwork);
|
||||
await receiverUser.ReceiveUTXO(Money.Coins(0.013m), btcPayNetwork);
|
||||
await receiverUser.ReceiveUTXO(Money.Coins(0.014m), btcPayNetwork);
|
||||
await senderUser.ReceiveUTXO(Money.Coins(0.021m), btcPayNetwork);
|
||||
await senderUser.ReceiveUTXO(Money.Coins(0.022m), btcPayNetwork);
|
||||
await senderUser.ReceiveUTXO(Money.Coins(0.023m), btcPayNetwork);
|
||||
await senderUser.ReceiveUTXO(Money.Coins(0.024m), btcPayNetwork);
|
||||
await senderUser.ReceiveUTXO(Money.Coins(0.025m), btcPayNetwork);
|
||||
await senderUser.ReceiveUTXO(Money.Coins(0.026m), btcPayNetwork);
|
||||
var senderChange = await senderUser.GetNewAddress(btcPayNetwork);
|
||||
|
||||
//Let's start the harassment
|
||||
invoice = receiverUser.BitPay.CreateInvoice(
|
||||
new Invoice() {Price = 0.02m, Currency = "BTC", FullNotifications = true});
|
||||
|
||||
var parsedBip21 = new BitcoinUrlBuilder(invoice.CryptoInfo.First().PaymentUrls.BIP21,
|
||||
tester.ExplorerClient.Network.NBitcoinNetwork);
|
||||
|
||||
var invoice2 = receiverUser.BitPay.CreateInvoice(
|
||||
new Invoice() {Price = 0.02m, Currency = "BTC", FullNotifications = true});
|
||||
var secondInvoiceParsedBip21 = new BitcoinUrlBuilder(invoice2.CryptoInfo.First().PaymentUrls.BIP21,
|
||||
tester.ExplorerClient.Network.NBitcoinNetwork);
|
||||
|
||||
var senderStore = await tester.PayTester.StoreRepository.FindStore(senderUser.StoreId);
|
||||
var paymentMethodId = new PaymentMethodId("BTC", PaymentTypes.BTCLike);
|
||||
var derivationSchemeSettings = senderStore.GetSupportedPaymentMethods(tester.NetworkProvider)
|
||||
.OfType<DerivationSchemeSettings>().SingleOrDefault(settings =>
|
||||
settings.PaymentId == paymentMethodId);
|
||||
|
||||
ReceivedCoin[] senderCoins = null;
|
||||
await TestUtils.EventuallyAsync(async () =>
|
||||
{
|
||||
senderCoins = await btcPayWallet.GetUnspentCoins(senderUser.DerivationScheme);
|
||||
Assert.Contains(senderCoins, coin => coin.Value.GetValue(btcPayNetwork) == 0.026m);
|
||||
});
|
||||
var coin = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.021m);
|
||||
var coin2 = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.022m);
|
||||
var coin3 = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.023m);
|
||||
var coin4 = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.024m);
|
||||
var coin5 = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.025m);
|
||||
var coin6 = senderCoins.Single(coin => coin.Value.GetValue(btcPayNetwork) == 0.026m);
|
||||
|
||||
var signingKeySettings = derivationSchemeSettings.GetSigningAccountKeySettings();
|
||||
signingKeySettings.RootFingerprint =
|
||||
senderUser.GenerateWalletResponseV.MasterHDKey.GetPublicKey().GetHDFingerPrint();
|
||||
|
||||
var extKey =
|
||||
senderUser.GenerateWalletResponseV.MasterHDKey.Derive(signingKeySettings.GetRootedKeyPath()
|
||||
.KeyPath);
|
||||
|
||||
|
||||
var n = tester.ExplorerClient.Network.NBitcoinNetwork;
|
||||
var Invoice1Coin1 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
|
||||
.SetChange(senderChange)
|
||||
.Send(parsedBip21.Address, parsedBip21.Amount)
|
||||
.AddCoins(coin.Coin)
|
||||
.AddKeys(extKey.Derive(coin.KeyPath))
|
||||
.SendEstimatedFees(new FeeRate(100m))
|
||||
.BuildTransaction(true);
|
||||
|
||||
var Invoice1Coin2 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
|
||||
.SetChange(senderChange)
|
||||
.Send(parsedBip21.Address, parsedBip21.Amount)
|
||||
.AddCoins(coin2.Coin)
|
||||
.AddKeys(extKey.Derive(coin2.KeyPath))
|
||||
.SendEstimatedFees(new FeeRate(100m))
|
||||
.BuildTransaction(true);
|
||||
|
||||
var Invoice2Coin1 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
|
||||
.SetChange(senderChange)
|
||||
.Send(secondInvoiceParsedBip21.Address, secondInvoiceParsedBip21.Amount)
|
||||
.AddCoins(coin.Coin)
|
||||
.AddKeys(extKey.Derive(coin.KeyPath))
|
||||
.SendEstimatedFees(new FeeRate(100m))
|
||||
.BuildTransaction(true);
|
||||
|
||||
var Invoice2Coin2 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
|
||||
.SetChange(senderChange)
|
||||
.Send(secondInvoiceParsedBip21.Address, secondInvoiceParsedBip21.Amount)
|
||||
.AddCoins(coin2.Coin)
|
||||
.AddKeys(extKey.Derive(coin2.KeyPath))
|
||||
.SendEstimatedFees(new FeeRate(100m))
|
||||
.BuildTransaction(true);
|
||||
|
||||
//Attempt 1: Send a signed tx to invoice 1 that does not pay the invoice at all
|
||||
//Result: reject
|
||||
// Assert.False((await tester.PayTester.HttpClient.PostAsync(endpoint,
|
||||
// new StringContent(Invoice2Coin1.ToHex(), Encoding.UTF8, "text/plain"))).IsSuccessStatusCode);
|
||||
|
||||
//Attempt 2: Create two transactions using different inputs and send them to the same invoice.
|
||||
//Result: Second Tx should be rejected.
|
||||
var Invoice1Coin1ResponseTx = await senderUser.SubmitPayjoin(invoice, Invoice1Coin1, btcPayNetwork);
|
||||
await senderUser.SubmitPayjoin(invoice, Invoice1Coin1, btcPayNetwork, "already-paid");
|
||||
var contributedInputsInvoice1Coin1ResponseTx =
|
||||
Invoice1Coin1ResponseTx.Inputs.Where(txin => coin.OutPoint != txin.PrevOut);
|
||||
Assert.Single(contributedInputsInvoice1Coin1ResponseTx);
|
||||
|
||||
//Attempt 3: Send the same inputs from invoice 1 to invoice 2 while invoice 1 tx has not been broadcasted
|
||||
//Result: Reject Tx1 but accept tx 2 as its inputs were never accepted by invoice 1
|
||||
await senderUser.SubmitPayjoin(invoice2, Invoice2Coin1, btcPayNetwork, "inputs-already-used");
|
||||
var Invoice2Coin2ResponseTx = await senderUser.SubmitPayjoin(invoice2, Invoice2Coin2, btcPayNetwork);
|
||||
|
||||
var contributedInputsInvoice2Coin2ResponseTx =
|
||||
Invoice2Coin2ResponseTx.Inputs.Where(txin => coin2.OutPoint != txin.PrevOut);
|
||||
Assert.Single(contributedInputsInvoice2Coin2ResponseTx);
|
||||
|
||||
//Attempt 4: Make tx that pays invoice 3 and 4 and submit to both
|
||||
//Result: reject on 4: the protocol should not worry about this complexity
|
||||
|
||||
var invoice3 = receiverUser.BitPay.CreateInvoice(
|
||||
new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true});
|
||||
var invoice3ParsedBip21 = new BitcoinUrlBuilder(invoice3.CryptoInfo.First().PaymentUrls.BIP21,
|
||||
tester.ExplorerClient.Network.NBitcoinNetwork);
|
||||
|
||||
|
||||
var invoice4 = receiverUser.BitPay.CreateInvoice(
|
||||
new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true});
|
||||
var invoice4ParsedBip21 = new BitcoinUrlBuilder(invoice4.CryptoInfo.First().PaymentUrls.BIP21,
|
||||
tester.ExplorerClient.Network.NBitcoinNetwork);
|
||||
|
||||
|
||||
var Invoice3AndInvoice4Coin3 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
|
||||
.SetChange(senderChange)
|
||||
.Send(invoice3ParsedBip21.Address, invoice3ParsedBip21.Amount)
|
||||
.Send(invoice4ParsedBip21.Address, invoice4ParsedBip21.Amount)
|
||||
.AddCoins(coin3.Coin)
|
||||
.AddKeys(extKey.Derive(coin3.KeyPath))
|
||||
.SendEstimatedFees(new FeeRate(100m))
|
||||
.BuildTransaction(true);
|
||||
|
||||
await senderUser.SubmitPayjoin(invoice3, Invoice3AndInvoice4Coin3, btcPayNetwork);
|
||||
await senderUser.SubmitPayjoin(invoice4, Invoice3AndInvoice4Coin3, btcPayNetwork, "already-paid");
|
||||
|
||||
//Attempt 5: Make tx that pays invoice 5 with 2 outputs
|
||||
//Result: proposed tx consolidates the outputs
|
||||
|
||||
var invoice5 = receiverUser.BitPay.CreateInvoice(
|
||||
new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true});
|
||||
var invoice5ParsedBip21 = new BitcoinUrlBuilder(invoice5.CryptoInfo.First().PaymentUrls.BIP21,
|
||||
tester.ExplorerClient.Network.NBitcoinNetwork);
|
||||
|
||||
var Invoice5Coin4TxBuilder = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
|
||||
.SetChange(senderChange)
|
||||
.Send(invoice5ParsedBip21.Address, invoice5ParsedBip21.Amount / 2)
|
||||
.Send(invoice5ParsedBip21.Address, invoice5ParsedBip21.Amount / 2)
|
||||
.AddCoins(coin4.Coin)
|
||||
.AddKeys(extKey.Derive(coin4.KeyPath))
|
||||
.SendEstimatedFees(new FeeRate(100m));
|
||||
|
||||
var Invoice5Coin4 = Invoice5Coin4TxBuilder.BuildTransaction(true);
|
||||
var Invoice5Coin4ResponseTx = await senderUser.SubmitPayjoin(invoice5, Invoice5Coin4, btcPayNetwork);
|
||||
Assert.Single(Invoice5Coin4ResponseTx.Outputs.To(invoice5ParsedBip21.Address));
|
||||
|
||||
//Attempt 10: send tx with rbf, broadcast payjoin tx, bump the rbf payjoin , attempt to submit tx again
|
||||
//Result: same tx gets sent back
|
||||
|
||||
//give the receiver some more utxos
|
||||
Assert.NotNull(await tester.ExplorerNode.SendToAddressAsync(
|
||||
(await btcPayWallet.ReserveAddressAsync(receiverUser.DerivationScheme)).Address,
|
||||
new Money(0.1m, MoneyUnit.BTC)));
|
||||
|
||||
var invoice6 = receiverUser.BitPay.CreateInvoice(
|
||||
new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true});
|
||||
var invoice6ParsedBip21 = new BitcoinUrlBuilder(invoice6.CryptoInfo.First().PaymentUrls.BIP21,
|
||||
tester.ExplorerClient.Network.NBitcoinNetwork);
|
||||
|
||||
var invoice6Coin5TxBuilder = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
|
||||
.SetChange(senderChange)
|
||||
.Send(invoice6ParsedBip21.Address, invoice6ParsedBip21.Amount)
|
||||
.AddCoins(coin5.Coin)
|
||||
.AddKeys(extKey.Derive(coin5.KeyPath))
|
||||
.SendEstimatedFees(new FeeRate(100m))
|
||||
.SetLockTime(0);
|
||||
|
||||
var invoice6Coin5 = invoice6Coin5TxBuilder
|
||||
.BuildTransaction(true);
|
||||
|
||||
var Invoice6Coin5Response1Tx =await senderUser.SubmitPayjoin(invoice6, invoice6Coin5, btcPayNetwork);
|
||||
var Invoice6Coin5Response1TxSigned = invoice6Coin5TxBuilder.SignTransaction(Invoice6Coin5Response1Tx);
|
||||
//broadcast the first payjoin
|
||||
await tester.ExplorerClient.BroadcastAsync(Invoice6Coin5Response1TxSigned);
|
||||
|
||||
// invoice6Coin5TxBuilder = invoice6Coin5TxBuilder.SendEstimatedFees(new FeeRate(100m));
|
||||
// var invoice6Coin5Bumpedfee = invoice6Coin5TxBuilder
|
||||
// .BuildTransaction(true);
|
||||
//
|
||||
// var Invoice6Coin5Response3 = await tester.PayTester.HttpClient.PostAsync(invoice6Endpoint,
|
||||
// new StringContent(invoice6Coin5Bumpedfee.ToHex(), Encoding.UTF8, "text/plain"));
|
||||
// Assert.True(Invoice6Coin5Response3.IsSuccessStatusCode);
|
||||
// var Invoice6Coin5Response3Tx =
|
||||
// Transaction.Parse(await Invoice6Coin5Response3.Content.ReadAsStringAsync(), n);
|
||||
// Assert.True(invoice6Coin5Bumpedfee.Inputs.All(txin =>
|
||||
// Invoice6Coin5Response3Tx.Inputs.Any(txin2 => txin2.PrevOut == txin.PrevOut)));
|
||||
|
||||
//Attempt 11:
|
||||
//send tx with rbt, broadcast payjoin,
|
||||
//create tx spending the original tx inputs with rbf to self,
|
||||
//Result: the exposed utxos are priorized in the next p2ep
|
||||
|
||||
//give the receiver some more utxos
|
||||
Assert.NotNull(await tester.ExplorerNode.SendToAddressAsync(
|
||||
(await btcPayWallet.ReserveAddressAsync(receiverUser.DerivationScheme)).Address,
|
||||
new Money(0.1m, MoneyUnit.BTC)));
|
||||
|
||||
var invoice7 = receiverUser.BitPay.CreateInvoice(
|
||||
new Invoice() {Price = 0.01m, Currency = "BTC", FullNotifications = true});
|
||||
var invoice7ParsedBip21 = new BitcoinUrlBuilder(invoice7.CryptoInfo.First().PaymentUrls.BIP21,
|
||||
tester.ExplorerClient.Network.NBitcoinNetwork);
|
||||
|
||||
var invoice7Coin6TxBuilder = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
|
||||
.SetChange(senderChange)
|
||||
.Send(invoice7ParsedBip21.Address, invoice7ParsedBip21.Amount)
|
||||
.AddCoins(coin6.Coin)
|
||||
.AddKeys(extKey.Derive(coin6.KeyPath))
|
||||
.SendEstimatedFees(new FeeRate(100m))
|
||||
.SetLockTime(0);
|
||||
|
||||
var invoice7Coin6Tx = invoice7Coin6TxBuilder
|
||||
.BuildTransaction(true);
|
||||
|
||||
var invoice7Coin6Response1Tx = await senderUser.SubmitPayjoin(invoice7, invoice7Coin6Tx, btcPayNetwork);
|
||||
var Invoice7Coin6Response1TxSigned = invoice7Coin6TxBuilder.SignTransaction(invoice7Coin6Response1Tx);
|
||||
var contributedInputsInvoice7Coin6Response1TxSigned =
|
||||
Invoice7Coin6Response1TxSigned.Inputs.Single(txin => coin6.OutPoint != txin.PrevOut);
|
||||
|
||||
|
||||
////var receiverWalletPayJoinState = payJoinStateProvider.Get(receiverWalletId);
|
||||
////Assert.Contains(receiverWalletPayJoinState.GetRecords(), item => item.InvoiceId == invoice7.Id);
|
||||
//broadcast the payjoin
|
||||
var res = (await tester.ExplorerClient.BroadcastAsync(Invoice7Coin6Response1TxSigned));
|
||||
Assert.True(res.Success);
|
||||
|
||||
// Paid with coinjoin
|
||||
await TestUtils.EventuallyAsync(async () =>
|
||||
{
|
||||
var invoiceEntity = await tester.PayTester.GetService<InvoiceRepository>().GetInvoice(invoice7.Id);
|
||||
Assert.Equal(InvoiceStatus.Paid, invoiceEntity.Status);
|
||||
Assert.Contains(invoiceEntity.GetPayments(), p => p.Accounted &&
|
||||
((BitcoinLikePaymentData)p.GetCryptoPaymentData()).PayjoinInformation is null);
|
||||
});
|
||||
////Assert.Contains(receiverWalletPayJoinState.GetRecords(), item => item.InvoiceId == invoice7.Id && item.TxSeen);
|
||||
|
||||
var invoice7Coin6Tx2 = tester.ExplorerClient.Network.NBitcoinNetwork.CreateTransactionBuilder()
|
||||
.SetChange(senderChange)
|
||||
.AddCoins(coin6.Coin)
|
||||
.SendAll(senderChange)
|
||||
.SubtractFees()
|
||||
.AddKeys(extKey.Derive(coin6.KeyPath))
|
||||
.SendEstimatedFees(new FeeRate(200m))
|
||||
.SetLockTime(0)
|
||||
.BuildTransaction(true);
|
||||
|
||||
//broadcast the "rbf cancel" tx
|
||||
res = (await tester.ExplorerClient.BroadcastAsync(invoice7Coin6Tx2));
|
||||
Assert.True(res.Success);
|
||||
|
||||
// Make a block, this should put back the invoice to new
|
||||
var blockhash = tester.ExplorerNode.Generate(1)[0];
|
||||
Assert.NotNull(await tester.ExplorerNode.GetRawTransactionAsync(invoice7Coin6Tx2.GetHash(), blockhash));
|
||||
Assert.Null(await tester.ExplorerNode.GetRawTransactionAsync(Invoice7Coin6Response1TxSigned.GetHash(), blockhash, false));
|
||||
// Now we should return to New
|
||||
OutPoint ourOutpoint = null;
|
||||
await TestUtils.EventuallyAsync(async () =>
|
||||
{
|
||||
var invoiceEntity = await tester.PayTester.GetService<InvoiceRepository>().GetInvoice(invoice7.Id);
|
||||
Assert.Equal(InvoiceStatus.New, invoiceEntity.Status);
|
||||
Assert.True(invoiceEntity.GetPayments().All(p => !p.Accounted));
|
||||
ourOutpoint = invoiceEntity.GetAllBitcoinPaymentData().First().PayjoinInformation.ContributedOutPoints[0];
|
||||
});
|
||||
var payjoinRepository = tester.PayTester.GetService<PayJoinRepository>();
|
||||
// The outpoint should now be available for next pj selection
|
||||
Assert.False(await payjoinRepository.TryUnlock(ourOutpoint));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -6,10 +6,12 @@ using BTCPayServer.Data;
|
||||
using BTCPayServer.Services.Rates;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Logging;
|
||||
using BTCPayServer.Payments;
|
||||
using BTCPayServer.Payments.Bitcoin;
|
||||
using BTCPayServer.Payments.Lightning;
|
||||
using BTCPayServer.Rating;
|
||||
using Logs = BTCPayServer.Tests.Logging.Logs;
|
||||
|
||||
namespace BTCPayServer.Tests
|
||||
{
|
||||
@ -41,8 +43,8 @@ namespace BTCPayServer.Tests
|
||||
|
||||
currencyPairRateResult.Add(new CurrencyPair("USD", "BTC"), Task.FromResult(rateResultUSDBTC));
|
||||
currencyPairRateResult.Add(new CurrencyPair("BTC", "USD"), Task.FromResult(rateResultBTCUSD));
|
||||
|
||||
handlerBTC = new BitcoinLikePaymentHandler(null, networkProvider, null, null);
|
||||
InvoiceLogs logs = new InvoiceLogs();
|
||||
handlerBTC = new BitcoinLikePaymentHandler(null, networkProvider, null, null, null);
|
||||
handlerLN = new LightningLikePaymentHandler(null, null, networkProvider, null);
|
||||
|
||||
#pragma warning restore CS0618
|
||||
|
@ -19,6 +19,7 @@ using System.Threading.Tasks;
|
||||
using BTCPayServer.Lightning;
|
||||
using BTCPayServer.Lightning.CLightning;
|
||||
using BTCPayServer.Models;
|
||||
using BTCPayServer.Services;
|
||||
using BTCPayServer.Views.Manage;
|
||||
using BTCPayServer.Views.Stores;
|
||||
using Newtonsoft.Json;
|
||||
@ -32,9 +33,9 @@ namespace BTCPayServer.Tests
|
||||
public IWebDriver Driver { get; set; }
|
||||
public ServerTester Server { get; set; }
|
||||
|
||||
public static SeleniumTester Create([CallerMemberNameAttribute] string scope = null)
|
||||
public static SeleniumTester Create([CallerMemberNameAttribute] string scope = null, bool newDb = false)
|
||||
{
|
||||
var server = ServerTester.Create(scope);
|
||||
var server = ServerTester.Create(scope, newDb);
|
||||
return new SeleniumTester()
|
||||
{
|
||||
Server = server
|
||||
@ -120,25 +121,24 @@ namespace BTCPayServer.Tests
|
||||
|
||||
return (usr, Driver.FindElement(By.Id("Id")).GetAttribute("value"));
|
||||
}
|
||||
|
||||
|
||||
public string GenerateWallet(string cryptoCode = "BTC", string seed = "", bool importkeys = false, bool privkeys = false)
|
||||
public Mnemonic GenerateWallet(string cryptoCode = "BTC", string seed = "", bool importkeys = false, bool privkeys = false)
|
||||
{
|
||||
Driver.FindElement(By.Id($"Modify{cryptoCode}")).ForceClick();
|
||||
Driver.FindElement(By.Id("import-from-btn")).ForceClick();
|
||||
Driver.FindElement(By.Id("nbxplorergeneratewalletbtn")).ForceClick();
|
||||
Thread.Sleep(200); // allow for modal to fade in
|
||||
Driver.WaitForElement(By.Id("ExistingMnemonic")).SendKeys(seed);
|
||||
SetCheckbox(Driver.FindElement(By.Id("SavePrivateKeys")), privkeys);
|
||||
SetCheckbox(Driver.FindElement(By.Id("ImportKeysToRPC")), importkeys);
|
||||
Driver.FindElement(By.Id("btn-generate")).ForceClick();
|
||||
SetCheckbox(Driver.WaitForElement(By.Id("SavePrivateKeys")), privkeys);
|
||||
SetCheckbox(Driver.WaitForElement(By.Id("ImportKeysToRPC")), importkeys);
|
||||
Logs.Tester.LogInformation("Trying to click btn-generate");
|
||||
Driver.WaitForElement(By.Id("btn-generate")).ForceClick();
|
||||
AssertHappyMessage();
|
||||
if (string.IsNullOrEmpty(seed))
|
||||
{
|
||||
seed = Driver.FindElements(By.ClassName("alert-success")).First().FindElement(By.TagName("code")).Text;
|
||||
}
|
||||
Driver.FindElement(By.Id("Confirm")).ForceClick();
|
||||
AssertHappyMessage();
|
||||
return seed;
|
||||
return new Mnemonic(seed);
|
||||
}
|
||||
|
||||
public void AddDerivationScheme(string cryptoCode = "BTC", string derivationScheme = "xpub661MyMwAqRbcGABgHMUXDzPzH1tU7eZaAaJQXhDXsSxsqyQzQeU6kznNfSuAyqAK9UaWSaZaMFdNiY5BCF4zBPAzSnwfUAwUhwttuAKwfRX-[legacy]")
|
||||
@ -254,13 +254,14 @@ namespace BTCPayServer.Tests
|
||||
|
||||
if (value != element.Selected)
|
||||
{
|
||||
Logs.Tester.LogInformation("SetCheckbox recursion, trying to click again");
|
||||
SetCheckbox(element, value);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetCheckbox(SeleniumTester s, string inputName, bool value)
|
||||
public void SetCheckbox(SeleniumTester s, string checkboxId, bool value)
|
||||
{
|
||||
SetCheckbox(s.Driver.FindElement(By.Name(inputName)), value);
|
||||
SetCheckbox(s.Driver.WaitForElement(By.Id(checkboxId)), value);
|
||||
}
|
||||
|
||||
public void ScrollToElement(IWebElement element)
|
||||
@ -313,8 +314,38 @@ namespace BTCPayServer.Tests
|
||||
return id;
|
||||
}
|
||||
|
||||
public async Task FundStoreWallet(WalletId walletId, int coins = 1, decimal denomination = 1m)
|
||||
{
|
||||
GoToWalletReceive(walletId);
|
||||
Driver.FindElement(By.Id("generateButton")).Click();
|
||||
var addressStr = Driver.FindElement(By.Id("vue-address")).GetProperty("value");
|
||||
var address = BitcoinAddress.Create(addressStr, ((BTCPayNetwork)Server.NetworkProvider.GetNetwork(walletId.CryptoCode)).NBitcoinNetwork);
|
||||
for (int i = 0; i < coins; i++)
|
||||
{
|
||||
await Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(denomination));
|
||||
}
|
||||
}
|
||||
|
||||
public void PayInvoice(WalletId walletId, string invoiceId)
|
||||
{
|
||||
GoToInvoiceCheckout(invoiceId);
|
||||
var bip21 = Driver.FindElement(By.ClassName("payment__details__instruction__open-wallet__btn"))
|
||||
.GetAttribute("href");
|
||||
Assert.Contains($"{PayjoinClient.BIP21EndpointKey}", bip21);
|
||||
|
||||
GoToWalletSend(walletId);
|
||||
Driver.FindElement(By.Id("bip21parse")).Click();
|
||||
Driver.SwitchTo().Alert().SendKeys(bip21);
|
||||
Driver.SwitchTo().Alert().Accept();
|
||||
Driver.ScrollTo(By.Id("SendMenu"));
|
||||
Driver.FindElement(By.Id("SendMenu")).ForceClick();
|
||||
Driver.FindElement(By.CssSelector("button[value=nbx-seed]")).Click();
|
||||
Driver.FindElement(By.CssSelector("button[value=broadcast]")).ForceClick();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
private void CheckForJSErrors()
|
||||
{
|
||||
//wait for seleniun update: https://stackoverflow.com/questions/57520296/selenium-webdriver-3-141-0-driver-manage-logs-availablelogtypes-throwing-syste
|
||||
@ -338,6 +369,14 @@ namespace BTCPayServer.Tests
|
||||
|
||||
}
|
||||
|
||||
public void GoToWalletSend(WalletId walletId)
|
||||
{
|
||||
Driver.Navigate().GoToUrl(new Uri(Server.PayTester.ServerUri, $"wallets/{walletId}/send"));
|
||||
}
|
||||
|
||||
internal void GoToWalletReceive(WalletId walletId)
|
||||
{
|
||||
Driver.Navigate().GoToUrl(new Uri(Server.PayTester.ServerUri, $"wallets/{walletId}/receive"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -11,6 +11,9 @@ using System.Threading.Tasks;
|
||||
using System.Text.RegularExpressions;
|
||||
using BTCPayServer.Models;
|
||||
using NBitcoin.Payment;
|
||||
using BTCPayServer.Controllers;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Services.Wallets;
|
||||
|
||||
namespace BTCPayServer.Tests
|
||||
{
|
||||
@ -417,7 +420,68 @@ namespace BTCPayServer.Tests
|
||||
s.Driver.Quit();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact(Timeout = TestTimeout)]
|
||||
public async Task CanUseCoinSelection()
|
||||
{
|
||||
using (var s = SeleniumTester.Create())
|
||||
{
|
||||
await s.StartAsync();
|
||||
var userId = s.RegisterNewUser(true);
|
||||
var storeId = s.CreateNewStore().storeId;
|
||||
s.GenerateWallet("BTC", "", false, true);
|
||||
var walletId = new WalletId(storeId, "BTC");
|
||||
s.GoToWalletReceive(walletId);
|
||||
s.Driver.FindElement(By.Id("generateButton")).Click();
|
||||
var addressStr = s.Driver.FindElement(By.Id("vue-address")).GetProperty("value");
|
||||
var address = BitcoinAddress.Create(addressStr, ((BTCPayNetwork)s.Server.NetworkProvider.GetNetwork("BTC")).NBitcoinNetwork);
|
||||
await s.Server.ExplorerNode.GenerateAsync(1);
|
||||
for (int i = 0; i < 6; i++)
|
||||
{
|
||||
await s.Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(1.0m));
|
||||
}
|
||||
var targetTx = await s.Server.ExplorerNode.SendToAddressAsync(address, Money.Coins(1.2m));
|
||||
var tx = await s.Server.ExplorerNode.GetRawTransactionAsync(targetTx);
|
||||
var spentOutpoint = new OutPoint(targetTx, tx.Outputs.FindIndex(txout => txout.Value == Money.Coins(1.2m)));
|
||||
await TestUtils.EventuallyAsync(async () =>
|
||||
{
|
||||
var store = await s.Server.PayTester.StoreRepository.FindStore(storeId);
|
||||
var x = store.GetSupportedPaymentMethods(s.Server.NetworkProvider)
|
||||
.OfType<DerivationSchemeSettings>()
|
||||
.Single(settings => settings.PaymentId.CryptoCode == walletId.CryptoCode);
|
||||
Assert.Contains(
|
||||
await s.Server.PayTester.GetService<BTCPayWalletProvider>().GetWallet(walletId.CryptoCode)
|
||||
.GetUnspentCoins(x.AccountDerivation),
|
||||
coin => coin.OutPoint == spentOutpoint);
|
||||
});
|
||||
await s.Server.ExplorerNode.GenerateAsync(1);
|
||||
s.GoToWalletSend(walletId);
|
||||
s.Driver.FindElement(By.Id("advancedSettings")).Click();
|
||||
s.Driver.FindElement(By.Id("toggleInputSelection")).Click();
|
||||
s.Driver.WaitForElement(By.Id(spentOutpoint.ToString()));
|
||||
Assert.Equal("true", s.Driver.FindElement(By.Name("InputSelection")).GetAttribute("value").ToLowerInvariant());
|
||||
var el = s.Driver.FindElement(By.Id(spentOutpoint.ToString()));
|
||||
s.Driver.FindElement(By.Id(spentOutpoint.ToString())).Click();
|
||||
var inputSelectionSelect = s.Driver.FindElement(By.Name("SelectedInputs"));
|
||||
Assert.Single(inputSelectionSelect.FindElements(By.CssSelector("[selected]")));
|
||||
|
||||
var bob = new Key().PubKey.Hash.GetAddress(Network.RegTest);
|
||||
SetTransactionOutput(s, 0, bob, 0.3m);
|
||||
s.Driver.FindElement(By.Id("SendMenu")).Click();
|
||||
s.Driver.FindElement(By.Id("spendWithNBxplorer")).Click();
|
||||
s.Driver.FindElement(By.CssSelector("button[value=broadcast]")).ForceClick();
|
||||
var happyElement = s.AssertHappyMessage();
|
||||
var happyText = happyElement.Text;
|
||||
var txid = Regex.Match(happyText, @"\((.*)\)").Groups[1].Value;
|
||||
|
||||
tx = await s.Server.ExplorerNode.GetRawTransactionAsync(new uint256(txid));
|
||||
Assert.Single(tx.Inputs);
|
||||
Assert.Equal(spentOutpoint, tx.Inputs[0].PrevOut);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[Fact(Timeout = TestTimeout)]
|
||||
public async Task CanManageWallet()
|
||||
{
|
||||
@ -488,7 +552,7 @@ namespace BTCPayServer.Tests
|
||||
var mnemonic = s.GenerateWallet("BTC", "", true, true);
|
||||
|
||||
//lets import and save private keys
|
||||
var root = new Mnemonic(mnemonic).DeriveExtKey();
|
||||
var root = mnemonic.DeriveExtKey();
|
||||
invoiceId = s.CreateInvoice(storeId.storeId);
|
||||
invoice = await s.Server.PayTester.InvoiceRepository.GetInvoice( invoiceId);
|
||||
address = invoice.EntityToDTO().Addresses["BTC"];
|
||||
@ -518,18 +582,18 @@ namespace BTCPayServer.Tests
|
||||
Assert.Contains(tx.ToString(), s.Driver.PageSource);
|
||||
|
||||
|
||||
void SignWith(string signingSource)
|
||||
void SignWith(Mnemonic signingSource)
|
||||
{
|
||||
// Send to bob
|
||||
s.Driver.FindElement(By.Id("WalletSend")).Click();
|
||||
var bob = new Key().PubKey.Hash.GetAddress(Network.RegTest);
|
||||
SetTransactionOutput(0, bob, 1);
|
||||
SetTransactionOutput(s, 0, bob, 1);
|
||||
s.Driver.ScrollTo(By.Id("SendMenu"));
|
||||
s.Driver.FindElement(By.Id("SendMenu")).ForceClick();
|
||||
s.Driver.FindElement(By.CssSelector("button[value=seed]")).Click();
|
||||
|
||||
// Input the seed
|
||||
s.Driver.FindElement(By.Id("SeedOrKey")).SendKeys(signingSource + Keys.Enter);
|
||||
s.Driver.FindElement(By.Id("SeedOrKey")).SendKeys(signingSource.ToString() + Keys.Enter);
|
||||
|
||||
// Broadcast
|
||||
Assert.Contains(bob.ToString(), s.Driver.PageSource);
|
||||
@ -537,19 +601,6 @@ namespace BTCPayServer.Tests
|
||||
s.Driver.FindElement(By.CssSelector("button[value=broadcast]")).ForceClick();
|
||||
Assert.Equal(walletTransactionLink, s.Driver.Url);
|
||||
}
|
||||
|
||||
void SetTransactionOutput(int index, BitcoinAddress dest, decimal amount, bool subtract = false)
|
||||
{
|
||||
s.Driver.FindElement(By.Id($"Outputs_{index}__DestinationAddress")).SendKeys(dest.ToString());
|
||||
var amountElement = s.Driver.FindElement(By.Id($"Outputs_{index}__Amount"));
|
||||
amountElement.Clear();
|
||||
amountElement.SendKeys(amount.ToString());
|
||||
var checkboxElement = s.Driver.FindElement(By.Id($"Outputs_{index}__SubtractFeesFromOutput"));
|
||||
if (checkboxElement.Selected != subtract)
|
||||
{
|
||||
checkboxElement.Click();
|
||||
}
|
||||
}
|
||||
|
||||
SignWith(mnemonic);
|
||||
|
||||
@ -558,7 +609,7 @@ namespace BTCPayServer.Tests
|
||||
s.Driver.FindElement(By.Id("WalletSend")).Click();
|
||||
|
||||
var jack = new Key().PubKey.Hash.GetAddress(Network.RegTest);
|
||||
SetTransactionOutput(0, jack, 0.01m);
|
||||
SetTransactionOutput(s, 0, jack, 0.01m);
|
||||
s.Driver.ScrollTo(By.Id("SendMenu"));
|
||||
s.Driver.FindElement(By.Id("SendMenu")).ForceClick();
|
||||
|
||||
@ -589,5 +640,17 @@ namespace BTCPayServer.Tests
|
||||
|
||||
}
|
||||
}
|
||||
void SetTransactionOutput(SeleniumTester s, int index, BitcoinAddress dest, decimal amount, bool subtract = false)
|
||||
{
|
||||
s.Driver.FindElement(By.Id($"Outputs_{index}__DestinationAddress")).SendKeys(dest.ToString());
|
||||
var amountElement = s.Driver.FindElement(By.Id($"Outputs_{index}__Amount"));
|
||||
amountElement.Clear();
|
||||
amountElement.SendKeys(amount.ToString());
|
||||
var checkboxElement = s.Driver.FindElement(By.Id($"Outputs_{index}__SubtractFeesFromOutput"));
|
||||
if (checkboxElement.Selected != subtract)
|
||||
{
|
||||
checkboxElement.Click();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -29,13 +29,13 @@ namespace BTCPayServer.Tests
|
||||
{
|
||||
public class ServerTester : IDisposable
|
||||
{
|
||||
public static ServerTester Create([CallerMemberNameAttribute]string scope = null)
|
||||
public static ServerTester Create([CallerMemberNameAttribute]string scope = null, bool newDb = false)
|
||||
{
|
||||
return new ServerTester(scope);
|
||||
return new ServerTester(scope, newDb);
|
||||
}
|
||||
|
||||
string _Directory;
|
||||
public ServerTester(string scope)
|
||||
public ServerTester(string scope, bool newDb)
|
||||
{
|
||||
_Directory = scope;
|
||||
if (Directory.Exists(_Directory))
|
||||
@ -59,6 +59,12 @@ namespace BTCPayServer.Tests
|
||||
Postgres = GetEnvironment("TESTS_POSTGRES", "User ID=postgres;Host=127.0.0.1;Port=39372;Database=btcpayserver"),
|
||||
MySQL = GetEnvironment("TESTS_MYSQL", "User ID=root;Host=127.0.0.1;Port=33036;Database=btcpayserver")
|
||||
};
|
||||
if (newDb)
|
||||
{
|
||||
var r = RandomUtils.GetUInt32();
|
||||
PayTester.Postgres = PayTester.Postgres.Replace("btcpayserver", $"btcpayserver{r}");
|
||||
PayTester.MySQL = PayTester.MySQL.Replace("btcpayserver", $"btcpayserver{r}");
|
||||
}
|
||||
PayTester.Port = int.Parse(GetEnvironment("TESTS_PORT", Utils.FreeTcpPort().ToString(CultureInfo.InvariantCulture)), CultureInfo.InvariantCulture);
|
||||
PayTester.HostName = GetEnvironment("TESTS_HOSTNAME", "127.0.0.1");
|
||||
PayTester.InContainer = bool.Parse(GetEnvironment("TESTS_INCONTAINER", "false"));
|
||||
@ -66,6 +72,7 @@ namespace BTCPayServer.Tests
|
||||
PayTester.SSHPassword = GetEnvironment("TESTS_SSHPASSWORD", "opD3i2282D");
|
||||
PayTester.SSHKeyFile = GetEnvironment("TESTS_SSHKEYFILE", "");
|
||||
PayTester.SSHConnection = GetEnvironment("TESTS_SSHCONNECTION", "root@127.0.0.1:21622");
|
||||
PayTester.SocksEndpoint = GetEnvironment("TESTS_SOCKSENDPOINT", "localhost:9050");
|
||||
}
|
||||
|
||||
public void ActivateLTC()
|
||||
@ -138,6 +145,19 @@ namespace BTCPayServer.Tests
|
||||
await CustomerLightningD.Pay(bolt11);
|
||||
}
|
||||
|
||||
public async Task<T> WaitForEvent<T>(Func<Task> action)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var sub = PayTester.GetService<EventAggregator>().Subscribe<T>(evt =>
|
||||
{
|
||||
tcs.TrySetResult(evt);
|
||||
});
|
||||
await action.Invoke();
|
||||
var result = await tcs.Task;
|
||||
sub.Dispose();
|
||||
return result;
|
||||
}
|
||||
|
||||
public ILightningClient CustomerLightningD { get; set; }
|
||||
|
||||
public ILightningClient MerchantLightningD { get; private set; }
|
||||
|
@ -8,8 +8,10 @@ using NBitcoin;
|
||||
using NBitpayClient;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Amazon.S3.Model;
|
||||
using Xunit;
|
||||
using NBXplorer.DerivationStrategy;
|
||||
using BTCPayServer.Payments;
|
||||
@ -20,44 +22,88 @@ using BTCPayServer.Lightning.CLightning;
|
||||
using BTCPayServer.Data;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using NBXplorer.Models;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Events;
|
||||
using BTCPayServer.Services;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using BTCPayServer.Services.Wallets;
|
||||
using NBitcoin.Payment;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace BTCPayServer.Tests
|
||||
{
|
||||
public class TestAccount
|
||||
{
|
||||
ServerTester parent;
|
||||
|
||||
public TestAccount(ServerTester parent)
|
||||
{
|
||||
this.parent = parent;
|
||||
BitPay = new Bitpay(new Key(), parent.PayTester.ServerUri);
|
||||
}
|
||||
|
||||
public void GrantAccess()
|
||||
public void GrantAccess(bool isAdmin = false)
|
||||
{
|
||||
GrantAccessAsync().GetAwaiter().GetResult();
|
||||
GrantAccessAsync(isAdmin).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public async Task MakeAdmin()
|
||||
public async Task MakeAdmin(bool isAdmin = true)
|
||||
{
|
||||
var userManager = parent.PayTester.GetService<UserManager<ApplicationUser>>();
|
||||
var u = await userManager.FindByIdAsync(UserId);
|
||||
await userManager.AddToRoleAsync(u, Roles.ServerAdmin);
|
||||
if (isAdmin)
|
||||
await userManager.AddToRoleAsync(u, Roles.ServerAdmin);
|
||||
else
|
||||
await userManager.RemoveFromRoleAsync(u, Roles.ServerAdmin);
|
||||
IsAdmin = true;
|
||||
}
|
||||
|
||||
public void Register()
|
||||
public Task<BTCPayServerClient> CreateClient()
|
||||
{
|
||||
RegisterAsync().GetAwaiter().GetResult();
|
||||
return Task.FromResult(new BTCPayServerClient(parent.PayTester.ServerUri, RegisterDetails.Email,
|
||||
RegisterDetails.Password));
|
||||
}
|
||||
public async Task GrantAccessAsync()
|
||||
|
||||
public async Task<BTCPayServerClient> CreateClient(params string[] permissions)
|
||||
{
|
||||
await RegisterAsync();
|
||||
var manageController = parent.PayTester.GetController<ManageController>(UserId, StoreId, IsAdmin);
|
||||
var x = Assert.IsType<RedirectToActionResult>(await manageController.AddApiKey(
|
||||
new ManageController.AddApiKeyViewModel()
|
||||
{
|
||||
PermissionValues = permissions.Select(s => new ManageController.AddApiKeyViewModel.PermissionValueItem()
|
||||
{
|
||||
Permission = s,
|
||||
Value = true
|
||||
}).ToList()
|
||||
}));
|
||||
var statusMessage = manageController.TempData.GetStatusMessageModel();
|
||||
Assert.NotNull(statusMessage);
|
||||
var str = "<code class='alert-link'>";
|
||||
var apiKey = statusMessage.Html.Substring(statusMessage.Html.IndexOf(str) + str.Length);
|
||||
apiKey = apiKey.Substring(0, apiKey.IndexOf("</code>"));
|
||||
return new BTCPayServerClient(parent.PayTester.ServerUri, apiKey);
|
||||
}
|
||||
|
||||
public void Register(bool isAdmin = false)
|
||||
{
|
||||
RegisterAsync(isAdmin).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public async Task GrantAccessAsync(bool isAdmin = false)
|
||||
{
|
||||
await RegisterAsync(isAdmin);
|
||||
await CreateStoreAsync();
|
||||
var store = this.GetController<StoresController>();
|
||||
var pairingCode = BitPay.RequestClientAuthorization("test", Facade.Merchant);
|
||||
Assert.IsType<ViewResult>(await store.RequestPairing(pairingCode.ToString()));
|
||||
await store.Pair(pairingCode.ToString(), StoreId);
|
||||
}
|
||||
|
||||
public BTCPayServerClient CreateClientFromAPIKey(string apiKey)
|
||||
{
|
||||
return new BTCPayServerClient(parent.PayTester.ServerUri, apiKey);
|
||||
}
|
||||
|
||||
public void CreateStore()
|
||||
{
|
||||
CreateStoreAsync().GetAwaiter().GetResult();
|
||||
@ -70,6 +116,7 @@ namespace BTCPayServer.Tests
|
||||
store.NetworkFeeMode = mode;
|
||||
});
|
||||
}
|
||||
|
||||
public void ModifyStore(Action<StoreViewModel> modify)
|
||||
{
|
||||
var storeController = GetController<StoresController>();
|
||||
@ -87,44 +134,60 @@ namespace BTCPayServer.Tests
|
||||
public async Task CreateStoreAsync()
|
||||
{
|
||||
var store = this.GetController<UserStoresController>();
|
||||
await store.CreateStore(new CreateStoreViewModel() { Name = "Test Store" });
|
||||
await store.CreateStore(new CreateStoreViewModel() {Name = "Test Store"});
|
||||
StoreId = store.CreatedStoreId;
|
||||
parent.Stores.Add(StoreId);
|
||||
}
|
||||
|
||||
public BTCPayNetwork SupportedNetwork { get; set; }
|
||||
|
||||
public WalletId RegisterDerivationScheme(string crytoCode, bool segwit = false, bool importKeysToNBX = false)
|
||||
public WalletId RegisterDerivationScheme(string crytoCode, ScriptPubKeyType segwit = ScriptPubKeyType.Legacy, bool importKeysToNBX = false)
|
||||
{
|
||||
return RegisterDerivationSchemeAsync(crytoCode, segwit, importKeysToNBX).GetAwaiter().GetResult();
|
||||
}
|
||||
public async Task<WalletId> RegisterDerivationSchemeAsync(string cryptoCode, bool segwit = false, bool importKeysToNBX = false)
|
||||
|
||||
public async Task<WalletId> RegisterDerivationSchemeAsync(string cryptoCode, ScriptPubKeyType segwit = ScriptPubKeyType.Legacy,
|
||||
bool importKeysToNBX = false)
|
||||
{
|
||||
SupportedNetwork = parent.NetworkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
|
||||
var store = parent.PayTester.GetController<StoresController>(UserId, StoreId);
|
||||
GenerateWalletResponseV = await parent.ExplorerClient.GenerateWalletAsync(new GenerateWalletRequest()
|
||||
{
|
||||
ScriptPubKeyType = segwit ? ScriptPubKeyType.Segwit : ScriptPubKeyType.Legacy,
|
||||
SavePrivateKeys = importKeysToNBX
|
||||
ScriptPubKeyType = segwit,
|
||||
SavePrivateKeys = importKeysToNBX,
|
||||
});
|
||||
|
||||
await store.AddDerivationScheme(StoreId, new DerivationSchemeViewModel()
|
||||
{
|
||||
Enabled = true,
|
||||
CryptoCode = cryptoCode,
|
||||
Network = SupportedNetwork,
|
||||
RootFingerprint = GenerateWalletResponseV.AccountKeyPath.MasterFingerprint.ToString(),
|
||||
RootKeyPath = SupportedNetwork.GetRootKeyPath(),
|
||||
Source = "NBXplorer",
|
||||
AccountKey = GenerateWalletResponseV.AccountHDKey.Neuter().ToWif(),
|
||||
DerivationSchemeFormat = "BTCPay",
|
||||
KeyPath = GenerateWalletResponseV.AccountKeyPath.KeyPath.ToString(),
|
||||
DerivationScheme = DerivationScheme.ToString(),
|
||||
Confirmation = true
|
||||
}, cryptoCode);
|
||||
await store.AddDerivationScheme(StoreId,
|
||||
new DerivationSchemeViewModel()
|
||||
{
|
||||
Enabled = true,
|
||||
CryptoCode = cryptoCode,
|
||||
Network = SupportedNetwork,
|
||||
RootFingerprint = GenerateWalletResponseV.AccountKeyPath.MasterFingerprint.ToString(),
|
||||
RootKeyPath = SupportedNetwork.GetRootKeyPath(),
|
||||
Source = "NBXplorer",
|
||||
AccountKey = GenerateWalletResponseV.AccountHDKey.Neuter().ToWif(),
|
||||
DerivationSchemeFormat = "BTCPay",
|
||||
KeyPath = GenerateWalletResponseV.AccountKeyPath.KeyPath.ToString(),
|
||||
DerivationScheme = DerivationScheme.ToString(),
|
||||
Confirmation = true
|
||||
}, cryptoCode);
|
||||
return new WalletId(StoreId, cryptoCode);
|
||||
}
|
||||
|
||||
public async Task EnablePayJoin()
|
||||
{
|
||||
var storeController = parent.PayTester.GetController<StoresController>(UserId, StoreId);
|
||||
var storeVM =
|
||||
Assert.IsType<StoreViewModel>(Assert
|
||||
.IsType<ViewResult>(storeController.UpdateStore()).Model);
|
||||
|
||||
storeVM.PayJoinEnabled = true;
|
||||
|
||||
Assert.Equal(nameof(storeController.UpdateStore),
|
||||
Assert.IsType<RedirectToActionResult>(
|
||||
await storeController.UpdateStore(storeVM)).ActionName);
|
||||
}
|
||||
|
||||
public GenerateWalletResponse GenerateWalletResponseV { get; set; }
|
||||
|
||||
public DerivationStrategyBase DerivationScheme
|
||||
@ -135,7 +198,7 @@ namespace BTCPayServer.Tests
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RegisterAsync()
|
||||
private async Task RegisterAsync(bool isAdmin = false)
|
||||
{
|
||||
var account = parent.PayTester.GetController<AccountController>();
|
||||
RegisterDetails = new RegisterViewModel()
|
||||
@ -143,27 +206,33 @@ namespace BTCPayServer.Tests
|
||||
Email = Guid.NewGuid() + "@toto.com",
|
||||
ConfirmPassword = "Kitten0@",
|
||||
Password = "Kitten0@",
|
||||
IsAdmin = isAdmin
|
||||
};
|
||||
await account.Register(RegisterDetails);
|
||||
UserId = account.RegisteredUserId;
|
||||
IsAdmin = account.RegisteredAdmin;
|
||||
}
|
||||
|
||||
public RegisterViewModel RegisterDetails{ get; set; }
|
||||
public RegisterViewModel RegisterDetails { get; set; }
|
||||
|
||||
public Bitpay BitPay
|
||||
{
|
||||
get; set;
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
public string UserId
|
||||
{
|
||||
get; set;
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
public string StoreId
|
||||
{
|
||||
get; set;
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
public bool IsAdmin { get; internal set; }
|
||||
|
||||
public void RegisterLightningNode(string cryptoCode, LightningConnectionType connectionType)
|
||||
@ -179,19 +248,145 @@ namespace BTCPayServer.Tests
|
||||
if (connectionType == LightningConnectionType.Charge)
|
||||
connectionString = "type=charge;server=" + parent.MerchantCharge.Client.Uri.AbsoluteUri;
|
||||
else if (connectionType == LightningConnectionType.CLightning)
|
||||
connectionString = "type=clightning;server=" + ((CLightningClient)parent.MerchantLightningD).Address.AbsoluteUri;
|
||||
connectionString = "type=clightning;server=" +
|
||||
((CLightningClient)parent.MerchantLightningD).Address.AbsoluteUri;
|
||||
else if (connectionType == LightningConnectionType.LndREST)
|
||||
connectionString = $"type=lnd-rest;server={parent.MerchantLnd.Swagger.BaseUrl};allowinsecure=true";
|
||||
else
|
||||
throw new NotSupportedException(connectionType.ToString());
|
||||
|
||||
await storeController.AddLightningNode(StoreId, new LightningNodeViewModel()
|
||||
{
|
||||
ConnectionString = connectionString,
|
||||
SkipPortTest = true
|
||||
}, "save", "BTC");
|
||||
await storeController.AddLightningNode(StoreId,
|
||||
new LightningNodeViewModel() {ConnectionString = connectionString, SkipPortTest = true}, "save", "BTC");
|
||||
if (storeController.ModelState.ErrorCount != 0)
|
||||
Assert.False(true, storeController.ModelState.FirstOrDefault().Value.Errors[0].ErrorMessage);
|
||||
}
|
||||
|
||||
public async Task<Coin> ReceiveUTXO(Money value, BTCPayNetwork network)
|
||||
{
|
||||
var cashCow = parent.ExplorerNode;
|
||||
var btcPayWallet = parent.PayTester.GetService<BTCPayWalletProvider>().GetWallet(network);
|
||||
var address = (await btcPayWallet.ReserveAddressAsync(this.DerivationScheme)).Address;
|
||||
await parent.WaitForEvent<NewOnChainTransactionEvent>(async () =>
|
||||
{
|
||||
await cashCow.SendToAddressAsync(address, value);
|
||||
});
|
||||
int i = 0;
|
||||
while (i <30)
|
||||
{
|
||||
var result = (await btcPayWallet.GetUnspentCoins(DerivationScheme))
|
||||
.FirstOrDefault(c => c.ScriptPubKey == address.ScriptPubKey)?.Coin;
|
||||
if (result != null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
await Task.Delay(1000);
|
||||
i++;
|
||||
}
|
||||
Assert.False(true);
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<BitcoinAddress> GetNewAddress(BTCPayNetwork network)
|
||||
{
|
||||
var cashCow = parent.ExplorerNode;
|
||||
var btcPayWallet = parent.PayTester.GetService<BTCPayWalletProvider>().GetWallet(network);
|
||||
var address = (await btcPayWallet.ReserveAddressAsync(this.DerivationScheme)).Address;
|
||||
return address;
|
||||
}
|
||||
|
||||
public async Task<PSBT> Sign(PSBT psbt)
|
||||
{
|
||||
var btcPayWallet = parent.PayTester.GetService<BTCPayWalletProvider>()
|
||||
.GetWallet(psbt.Network.NetworkSet.CryptoCode);
|
||||
var explorerClient = parent.PayTester.GetService<ExplorerClientProvider>()
|
||||
.GetExplorerClient(psbt.Network.NetworkSet.CryptoCode);
|
||||
psbt = (await explorerClient.UpdatePSBTAsync(new UpdatePSBTRequest()
|
||||
{
|
||||
DerivationScheme = DerivationScheme, PSBT = psbt
|
||||
})).PSBT;
|
||||
return psbt.SignAll(this.DerivationScheme, GenerateWalletResponseV.AccountHDKey,
|
||||
GenerateWalletResponseV.AccountKeyPath);
|
||||
}
|
||||
|
||||
public async Task<PSBT> SubmitPayjoin(Invoice invoice, PSBT psbt, string expectedError = null, bool senderError= false)
|
||||
{
|
||||
var endpoint = GetPayjoinEndpoint(invoice, psbt.Network);
|
||||
if (endpoint == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var pjClient = parent.PayTester.GetService<PayjoinClient>();
|
||||
var storeRepository = parent.PayTester.GetService<StoreRepository>();
|
||||
var store = await storeRepository.FindStore(StoreId);
|
||||
var settings = store.GetSupportedPaymentMethods(parent.NetworkProvider).OfType<DerivationSchemeSettings>()
|
||||
.First();
|
||||
Logs.Tester.LogInformation($"Proposing {psbt.GetGlobalTransaction().GetHash()}");
|
||||
if (expectedError is null && !senderError)
|
||||
{
|
||||
var proposed = await pjClient.RequestPayjoin(endpoint, settings, psbt, default);
|
||||
Logs.Tester.LogInformation($"Proposed payjoin is {proposed.GetGlobalTransaction().GetHash()}");
|
||||
Assert.NotNull(proposed);
|
||||
return proposed;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (senderError)
|
||||
{
|
||||
await Assert.ThrowsAsync<PayjoinSenderException>(async () => await pjClient.RequestPayjoin(endpoint, settings, psbt, default));
|
||||
}
|
||||
else
|
||||
{
|
||||
var ex = await Assert.ThrowsAsync<PayjoinReceiverException>(async () => await pjClient.RequestPayjoin(endpoint, settings, psbt, default));
|
||||
Assert.Equal(expectedError, ex.ErrorCode);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Transaction> SubmitPayjoin(Invoice invoice, Transaction transaction, BTCPayNetwork network,
|
||||
string expectedError = null)
|
||||
{
|
||||
var response =
|
||||
await SubmitPayjoinCore(transaction.ToHex(), invoice, network.NBitcoinNetwork, expectedError);
|
||||
if (response == null)
|
||||
return null;
|
||||
var signed = Transaction.Parse(await response.Content.ReadAsStringAsync(), network.NBitcoinNetwork);
|
||||
return signed;
|
||||
}
|
||||
|
||||
async Task<HttpResponseMessage> SubmitPayjoinCore(string content, Invoice invoice, Network network,
|
||||
string expectedError)
|
||||
{
|
||||
var endpoint = GetPayjoinEndpoint(invoice, network);
|
||||
var response = await parent.PayTester.HttpClient.PostAsync(endpoint,
|
||||
new StringContent(content, Encoding.UTF8, "text/plain"));
|
||||
if (expectedError != null)
|
||||
{
|
||||
Assert.False(response.IsSuccessStatusCode);
|
||||
var error = JObject.Parse(await response.Content.ReadAsStringAsync());
|
||||
Assert.Equal(expectedError, error["errorCode"].Value<string>());
|
||||
return null;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var error = JObject.Parse(await response.Content.ReadAsStringAsync());
|
||||
Assert.True(false,
|
||||
$"Error: {error["errorCode"].Value<string>()}: {error["message"].Value<string>()}");
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private static Uri GetPayjoinEndpoint(Invoice invoice, Network network)
|
||||
{
|
||||
var parsedBip21 = new BitcoinUrlBuilder(
|
||||
invoice.CryptoInfo.First(c => c.CryptoCode == network.NetworkSet.CryptoCode).PaymentUrls.BIP21,
|
||||
network);
|
||||
return parsedBip21.UnknowParameters.TryGetValue($"{PayjoinClient.BIP21EndpointKey}", out var uri) ? new Uri(uri, UriKind.Absolute) : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ using Xunit.Sdk;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Xunit;
|
||||
|
||||
namespace BTCPayServer.Tests
|
||||
{
|
||||
@ -41,6 +42,12 @@ namespace BTCPayServer.Tests
|
||||
return Path.Combine(directory.FullName, "TestData", relativeFilePath);
|
||||
}
|
||||
|
||||
public static T AssertType<T>(this object obj)
|
||||
{
|
||||
Assert.IsType<T>(obj);
|
||||
return (T)obj;
|
||||
}
|
||||
|
||||
public static FormFile GetFormFile(string filename, string content)
|
||||
{
|
||||
File.WriteAllText(filename, content);
|
||||
|
File diff suppressed because it is too large
Load Diff
5
BTCPayServer.Tests/docker-bitcoin-generate.sh
Executable file
5
BTCPayServer.Tests/docker-bitcoin-generate.sh
Executable file
@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
bitcoind_container_id="$(docker ps -q --filter label=com.docker.compose.project=btcpayservertests --filter label=com.docker.compose.service=bitcoind)"
|
||||
address=$(docker exec -ti $bitcoind_container_id bitcoin-cli -datadir="/data" getnewaddress)
|
||||
docker exec -ti $bitcoind_container_id bitcoin-cli -datadir="/data" generatetoaddress "$@" "$address"
|
@ -3,26 +3,26 @@ version: "3"
|
||||
services:
|
||||
|
||||
monerod:
|
||||
image: kukks/docker-monero:test
|
||||
image: btcpayserver/monero:0.15.0.1-amd64
|
||||
restart: unless-stopped
|
||||
container_name: xmr_monerod
|
||||
entrypoint: monerod --fixed-difficulty 100 --rpc-bind-ip=0.0.0.0 --confirm-external-bind --rpc-bind-port=18081 --non-interactive --block-notify="/scripts/notifier.sh https://127.0.0.1:14142/monerolikedaemoncallback/block?cryptoCode=xmr&hash=%s" --testnet --no-igd --hide-my-port --no-sync --offline
|
||||
entrypoint: sleep 999999
|
||||
# entrypoint: monerod --fixed-difficulty 200 --rpc-bind-ip=0.0.0.0 --confirm-external-bind --rpc-bind-port=18081 --block-notify="/bin/sh ./scripts/notifier.sh -k -X GET https://host.docker.internal:14142/monerolikedaemoncallback/block?cryptoCode=xmr&hash=%s" --testnet --no-igd --hide-my-port --offline
|
||||
volumes:
|
||||
- "monero_data:/home/monero/.bitmonero"
|
||||
ports:
|
||||
- "18081:18081"
|
||||
monero_wallet:
|
||||
image: kukks/docker-monero:test
|
||||
image: btcpayserver/monero:0.15.0.1-amd64
|
||||
restart: unless-stopped
|
||||
container_name: xmr_wallet_rpc
|
||||
entrypoint: monero-wallet-rpc --testnet --rpc-bind-ip=0.0.0.0 --disable-rpc-login --confirm-external-bind --rpc-bind-port=18082 --non-interactive --trusted-daemon --daemon-address=127.0.0.1:18081 --wallet-file=/wallet/wallet.keys --tx-notify="/scripts/notifier.sh https://127.0.0.1:14142/monerolikedaemoncallback/tx?cryptoCode=xmr&hash=%s"
|
||||
entrypoint: monero-wallet-rpc --testnet --rpc-bind-ip=0.0.0.0 --disable-rpc-login --confirm-external-bind --rpc-bind-port=18082 --non-interactive --trusted-daemon --daemon-address=monerod:18081 --wallet-file=/wallet/wallet.keys --password-file=/wallet/password --tx-notify="/bin/sh ./scripts/notifier.sh -k -X GET https://host.docker.internal:14142/monerolikedaemoncallback/tx?cryptoCode=xmr&hash=%s"
|
||||
ports:
|
||||
- "18082:18082"
|
||||
volumes:
|
||||
- "monero_wallet:/wallet"
|
||||
- "./monero_wallet:/wallet"
|
||||
depends_on:
|
||||
- monerod
|
||||
|
||||
volumes:
|
||||
monero_data:
|
||||
monero_wallet:
|
||||
|
@ -27,6 +27,7 @@ services:
|
||||
TESTS_SSHCONNECTION: "root@sshd:22"
|
||||
TESTS_SSHPASSWORD: ""
|
||||
TESTS_SSHKEYFILE: ""
|
||||
TESTS_SOCKSENDPOINT: "tor:9050"
|
||||
expose:
|
||||
- "80"
|
||||
links:
|
||||
@ -51,6 +52,7 @@ services:
|
||||
- customer_lnd
|
||||
- merchant_lnd
|
||||
- sshd
|
||||
- tor
|
||||
|
||||
sshd:
|
||||
build:
|
||||
@ -76,7 +78,7 @@ services:
|
||||
- customer_lnd
|
||||
- merchant_lnd
|
||||
nbxplorer:
|
||||
image: nicolasdorier/nbxplorer:2.1.8
|
||||
image: nicolasdorier/nbxplorer:2.1.24
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "32838:32838"
|
||||
@ -319,6 +321,21 @@ services:
|
||||
links:
|
||||
- bitcoind
|
||||
|
||||
tor:
|
||||
restart: unless-stopped
|
||||
image: btcpayserver/tor:0.4.1.5
|
||||
container_name: tor
|
||||
environment:
|
||||
TOR_PASSWORD: btcpayserver
|
||||
ports:
|
||||
- "9050:9050" # SOCKS
|
||||
- "9051:9051" # Tor Control
|
||||
volumes:
|
||||
- "tor_datadir:/home/tor/.tor"
|
||||
- "torrcdir:/usr/local/etc/tor"
|
||||
- "tor_servicesdir:/var/lib/tor/hidden_services"
|
||||
|
||||
|
||||
volumes:
|
||||
sshd_datadir:
|
||||
bitcoin_datadir:
|
||||
@ -328,3 +345,6 @@ volumes:
|
||||
lightning_charge_datadir:
|
||||
customer_lnd_datadir:
|
||||
merchant_lnd_datadir:
|
||||
tor_datadir:
|
||||
torrcdir:
|
||||
tor_servicesdir:
|
||||
|
@ -1,8 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Import Project="../Build/Version.csproj" Condition="Exists('../Build/Version.csproj')" />
|
||||
<Import Project="../Build/Common.csproj" />
|
||||
<PropertyGroup Condition="'$(Configuration)' == 'Debug'">
|
||||
<PropertyGroup Condition="'$(Configuration)' == 'Debug' And '$(RazorCompileOnBuild)' != 'true'">
|
||||
<RazorCompileOnBuild>false</RazorCompileOnBuild>
|
||||
<DefineConstants>$(DefineConstants);RAZOR_RUNTIME_COMPILE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
@ -30,7 +31,7 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BTCPayServer.Hwi" Version="1.1.3" />
|
||||
<PackageReference Include="BTCPayServer.Lightning.All" Version="1.1.8" />
|
||||
<PackageReference Include="BTCPayServer.Lightning.All" Version="1.1.9" />
|
||||
<PackageReference Include="BuildBundlerMinifier" Version="3.2.435" />
|
||||
<PackageReference Include="BundlerMinifier.Core" Version="3.2.435" />
|
||||
<PackageReference Include="BundlerMinifier.TagHelpers" Version="3.2.435" />
|
||||
@ -41,14 +42,13 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="NBitpayClient" Version="1.0.0.35" />
|
||||
<PackageReference Include="NBitpayClient" Version="1.0.0.38" />
|
||||
<PackageReference Include="DBriize" Version="1.0.1.3" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
|
||||
<PackageReference Include="NicolasDorier.CommandLine" Version="1.0.0.2" />
|
||||
<PackageReference Include="NicolasDorier.CommandLine.Configuration" Version="1.0.0.3" />
|
||||
<PackageReference Include="NicolasDorier.RateLimits" Version="1.1.0" />
|
||||
<PackageReference Include="NicolasDorier.StandardConfiguration" Version="1.0.0.18" />
|
||||
<PackageReference Include="NSwag.AspNetCore" Version="13.2.2" />
|
||||
<PackageReference Include="Serilog" Version="2.9.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="3.2.0" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="4.1.0" />
|
||||
@ -67,7 +67,7 @@
|
||||
<PackageReference Include="TwentyTwenty.Storage.Local" Version="2.12.1" />
|
||||
<PackageReference Include="U2F.Core" Version="1.0.4" />
|
||||
<PackageReference Include="YamlDotNet" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="3.1.1" Condition="'$(Configuration)' == 'Debug'" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="3.1.1" Condition="'$(RazorCompileOnBuild)' != 'true'" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="3.1.1" />
|
||||
</ItemGroup>
|
||||
|
||||
@ -121,9 +121,11 @@
|
||||
<Folder Include="wwwroot\vendor\highlightjs\" />
|
||||
<Folder Include="wwwroot\vendor\summernote" />
|
||||
<Folder Include="wwwroot\vendor\u2f" />
|
||||
<Folder Include="wwwroot\vendor\vue-qrcode-reader" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\BTCPayServer.Client\BTCPayServer.Client.csproj" />
|
||||
<ProjectReference Include="..\BTCPayServer.Data\BTCPayServer.Data.csproj" />
|
||||
<ProjectReference Include="..\BTCPayServer.Rating\BTCPayServer.Rating.csproj" />
|
||||
<ProjectReference Include="..\BTCPayServer.Common\BTCPayServer.Common.csproj" />
|
||||
@ -206,8 +208,6 @@
|
||||
<Content Update="Views\Wallets\WalletTransactions.cshtml">
|
||||
<Pack>$(IncludeRazorContentInPack)</Pack>
|
||||
</Content>
|
||||
<Content Remove="Views\Server\EditGoogleCloudStorageStorageProvider.cshtml">
|
||||
</Content>
|
||||
<Content Update="Views\Wallets\_Nav.cshtml">
|
||||
<Pack>$(IncludeRazorContentInPack)</Pack>
|
||||
</Content>
|
||||
@ -222,4 +222,6 @@
|
||||
<ItemGroup>
|
||||
<_ContentIncludedByDefault Remove="Views\Authorization\Authorize.cshtml" />
|
||||
</ItemGroup>
|
||||
|
||||
<ProjectExtensions><VisualStudio><UserProperties wwwroot_4swagger_4v1_4swagger_1template_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" /></VisualStudio></ProjectExtensions>
|
||||
</Project>
|
||||
|
@ -23,6 +23,7 @@ using BTCPayServer.U2F.Models;
|
||||
using Newtonsoft.Json;
|
||||
using NicolasDorier.RateLimits;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Events;
|
||||
using U2F.Core.Exceptions;
|
||||
|
||||
namespace BTCPayServer.Controllers
|
||||
@ -40,6 +41,7 @@ namespace BTCPayServer.Controllers
|
||||
Configuration.BTCPayServerOptions _Options;
|
||||
private readonly BTCPayServerEnvironment _btcPayServerEnvironment;
|
||||
public U2FService _u2FService;
|
||||
private readonly EventAggregator _eventAggregator;
|
||||
ILogger _logger;
|
||||
|
||||
public AccountController(
|
||||
@ -51,7 +53,8 @@ namespace BTCPayServer.Controllers
|
||||
SettingsRepository settingsRepository,
|
||||
Configuration.BTCPayServerOptions options,
|
||||
BTCPayServerEnvironment btcPayServerEnvironment,
|
||||
U2FService u2FService)
|
||||
U2FService u2FService,
|
||||
EventAggregator eventAggregator)
|
||||
{
|
||||
this.storeRepository = storeRepository;
|
||||
_userManager = userManager;
|
||||
@ -62,6 +65,7 @@ namespace BTCPayServer.Controllers
|
||||
_Options = options;
|
||||
_btcPayServerEnvironment = btcPayServerEnvironment;
|
||||
_u2FService = u2FService;
|
||||
_eventAggregator = eventAggregator;
|
||||
_logger = Logs.PayServer;
|
||||
}
|
||||
|
||||
@ -400,6 +404,7 @@ namespace BTCPayServer.Controllers
|
||||
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
[RateLimitsFilter(ZoneLimits.Register, Scope = RateLimitsScope.RemoteAddress)]
|
||||
public async Task<IActionResult> Register(string returnUrl = null, bool logon = true, bool useBasicLayout = false)
|
||||
{
|
||||
if (!CanLoginOrRegister())
|
||||
@ -439,7 +444,6 @@ namespace BTCPayServer.Controllers
|
||||
if (result.Succeeded)
|
||||
{
|
||||
var admin = await _userManager.GetUsersInRoleAsync(Roles.ServerAdmin);
|
||||
Logs.PayServer.LogInformation($"A new user just registered {user.Email} {(admin.Count == 0 ? "(admin)" : "")}");
|
||||
if (admin.Count == 0 || (model.IsAdmin && _Options.AllowAdminRegistration))
|
||||
{
|
||||
await _RoleManager.CreateAsync(new IdentityRole(Roles.ServerAdmin));
|
||||
@ -450,17 +454,21 @@ namespace BTCPayServer.Controllers
|
||||
if (_Options.DisableRegistration)
|
||||
{
|
||||
// Once the admin user has been created lock subsequent user registrations (needs to be disabled for unit tests that require multiple users).
|
||||
Logs.PayServer.LogInformation("First admin created, disabling subscription (disable-registration is set to true)");
|
||||
policies.LockSubscription = true;
|
||||
await _SettingsRepository.UpdateSetting(policies);
|
||||
}
|
||||
RegisteredAdmin = true;
|
||||
}
|
||||
|
||||
var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
|
||||
var callbackUrl = Url.EmailConfirmationLink(user.Id, code, Request.Scheme);
|
||||
_eventAggregator.Publish(new UserRegisteredEvent()
|
||||
{
|
||||
RequestUri = Request.GetAbsoluteRootUri(),
|
||||
User = user,
|
||||
Admin = RegisteredAdmin
|
||||
});
|
||||
RegisteredUserId = user.Id;
|
||||
|
||||
_EmailSenderFactory.GetEmailSender().SendEmailConfirmation(model.Email, callbackUrl);
|
||||
if (!policies.RequiresConfirmedEmail)
|
||||
{
|
||||
if (logon)
|
||||
|
@ -83,7 +83,7 @@ namespace BTCPayServer.Controllers
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
{
|
||||
Html =
|
||||
$"Error: You need to create at least one store. <a href='{(Url.Action("CreateStore", "UserStores"))}'>Create store</a>",
|
||||
$"Error: You need to create at least one store. <a href='{(Url.Action("CreateStore", "UserStores"))}' class='alert-link'>Create store</a>",
|
||||
Severity = StatusMessageModel.StatusSeverity.Error
|
||||
});
|
||||
return RedirectToAction(nameof(ListApps));
|
||||
@ -103,7 +103,7 @@ namespace BTCPayServer.Controllers
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
{
|
||||
Html =
|
||||
$"Error: You need to create at least one store. <a href='{(Url.Action("CreateStore", "UserStores"))}'>Create store</a>",
|
||||
$"Error: You need to create at least one store. <a href='{(Url.Action("CreateStore", "UserStores"))}' class='alert-link'>Create store</a>",
|
||||
Severity = StatusMessageModel.StatusSeverity.Error
|
||||
});
|
||||
return RedirectToAction(nameof(ListApps));
|
||||
|
@ -237,6 +237,10 @@ namespace BTCPayServer.Controllers
|
||||
[EnableCors(CorsPolicies.All)]
|
||||
public async Task<IActionResult> ContributeToCrowdfund(string appId, ContributeToCrowdfund request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.Amount <= 0)
|
||||
{
|
||||
return NotFound("Please provide an amount greater than 0");
|
||||
}
|
||||
var app = await _AppService.GetApp(appId, AppType.Crowdfund, true);
|
||||
|
||||
if (app == null)
|
||||
|
94
BTCPayServer/Controllers/GreenField/ApiKeysController.cs
Normal file
94
BTCPayServer/Controllers/GreenField/ApiKeysController.cs
Normal file
@ -0,0 +1,94 @@
|
||||
using System.Threading.Tasks;
|
||||
using System.Linq;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Security;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using BTCPayServer.Security.GreenField;
|
||||
using NBitcoin.DataEncoders;
|
||||
using NBitcoin;
|
||||
|
||||
namespace BTCPayServer.Controllers.GreenField
|
||||
{
|
||||
[ApiController]
|
||||
[Authorize(AuthenticationSchemes = AuthenticationSchemes.GreenfieldAPIKeys)]
|
||||
public class ApiKeysController : ControllerBase
|
||||
{
|
||||
private readonly APIKeyRepository _apiKeyRepository;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
|
||||
public ApiKeysController(APIKeyRepository apiKeyRepository, UserManager<ApplicationUser> userManager)
|
||||
{
|
||||
_apiKeyRepository = apiKeyRepository;
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
[HttpGet("~/api/v1/api-keys/current")]
|
||||
public async Task<ActionResult<ApiKeyData>> GetKey()
|
||||
{
|
||||
if (!ControllerContext.HttpContext.GetAPIKey(out var apiKey))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
var data = await _apiKeyRepository.GetKey(apiKey);
|
||||
return Ok(FromModel(data));
|
||||
}
|
||||
|
||||
[HttpPost("~/api/v1/api-keys")]
|
||||
[Authorize(Policy = Policies.Unrestricted, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||
public async Task<ActionResult<ApiKeyData>> CreateKey(CreateApiKeyRequest request)
|
||||
{
|
||||
if (request is null)
|
||||
return BadRequest();
|
||||
var key = new APIKeyData()
|
||||
{
|
||||
Id = Encoders.Hex.EncodeData(RandomUtils.GetBytes(20)),
|
||||
Type = APIKeyType.Permanent,
|
||||
UserId = _userManager.GetUserId(User),
|
||||
Label = request.Label
|
||||
};
|
||||
key.SetBlob(new APIKeyBlob()
|
||||
{
|
||||
Permissions = request.Permissions.Select(p => p.ToString()).Distinct().ToArray()
|
||||
});
|
||||
await _apiKeyRepository.CreateKey(key);
|
||||
return Ok(FromModel(key));
|
||||
}
|
||||
|
||||
[HttpDelete("~/api/v1/api-keys/current")]
|
||||
[Authorize(AuthenticationSchemes = AuthenticationSchemes.GreenfieldAPIKeys)]
|
||||
public Task<IActionResult> RevokeCurrentKey()
|
||||
{
|
||||
if (!ControllerContext.HttpContext.GetAPIKey(out var apiKey))
|
||||
{
|
||||
// Should be impossible (we force apikey auth)
|
||||
return Task.FromResult<IActionResult>(BadRequest());
|
||||
}
|
||||
return RevokeKey(apiKey);
|
||||
}
|
||||
[HttpDelete("~/api/v1/api-keys/{apikey}", Order = 1)]
|
||||
[Authorize(Policy = Policies.Unrestricted, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||
public async Task<IActionResult> RevokeKey(string apikey)
|
||||
{
|
||||
if (string.IsNullOrEmpty(apikey))
|
||||
return BadRequest();
|
||||
if (await _apiKeyRepository.Remove(apikey, _userManager.GetUserId(User)))
|
||||
return Ok();
|
||||
else
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
private static ApiKeyData FromModel(APIKeyData data)
|
||||
{
|
||||
return new ApiKeyData()
|
||||
{
|
||||
Permissions = Permission.ToPermissions(data.GetBlob().Permissions).ToArray(),
|
||||
ApiKey = data.Id,
|
||||
Label = data.Label ?? string.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
@ -1,20 +1,20 @@
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Security;
|
||||
using BTCPayServer.Security.APIKeys;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace BTCPayServer.Controllers.RestApi
|
||||
namespace BTCPayServer.Controllers.GreenField
|
||||
{
|
||||
/// <summary>
|
||||
/// this controller serves as a testing endpoint for our api key unit tests
|
||||
/// </summary>
|
||||
[Route("api/test/apikey")]
|
||||
[ApiController]
|
||||
[Authorize(AuthenticationSchemes = AuthenticationSchemes.ApiKey)]
|
||||
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||
public class TestApiKeyController : ControllerBase
|
||||
{
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
@ -27,45 +27,45 @@ namespace BTCPayServer.Controllers.RestApi
|
||||
}
|
||||
|
||||
[HttpGet("me/id")]
|
||||
[Authorize(Policy = Policies.CanViewProfile, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||
public string GetCurrentUserId()
|
||||
{
|
||||
return _userManager.GetUserId(User);
|
||||
}
|
||||
|
||||
[HttpGet("me")]
|
||||
[Authorize(Policy = Policies.CanViewProfile, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||
public async Task<ApplicationUser> GetCurrentUser()
|
||||
{
|
||||
return await _userManager.GetUserAsync(User);
|
||||
}
|
||||
|
||||
[HttpGet("me/is-admin")]
|
||||
[Authorize(Policy = Policies.CanModifyServerSettings.Key, AuthenticationSchemes = AuthenticationSchemes.ApiKey)]
|
||||
[Authorize(Policy = Policies.CanModifyServerSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||
public bool AmIAnAdmin()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
[HttpGet("me/stores")]
|
||||
[Authorize(Policy = Policies.CanListStoreSettings.Key,
|
||||
AuthenticationSchemes = AuthenticationSchemes.ApiKey)]
|
||||
public async Task<StoreData[]> GetCurrentUserStores()
|
||||
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||
public StoreData[] GetCurrentUserStores()
|
||||
{
|
||||
return await User.GetStores(_userManager, _storeRepository);
|
||||
return this.HttpContext.GetStoresData();
|
||||
}
|
||||
|
||||
[HttpGet("me/stores/actions")]
|
||||
[Authorize(Policy = Policies.CanModifyStoreSettings.Key,
|
||||
AuthenticationSchemes = AuthenticationSchemes.ApiKey)]
|
||||
public bool CanDoNonImplicitStoreActions()
|
||||
|
||||
[HttpGet("me/stores/{storeId}/can-view")]
|
||||
[Authorize(Policy = Policies.CanViewStoreSettings,
|
||||
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||
public bool CanViewStore(string storeId)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
[HttpGet("me/stores/{storeId}/can-edit")]
|
||||
[Authorize(Policy = Policies.CanModifyStoreSettings.Key,
|
||||
AuthenticationSchemes = AuthenticationSchemes.ApiKey)]
|
||||
public bool CanEdit(string storeId)
|
||||
[Authorize(Policy = Policies.CanModifyStoreSettings,
|
||||
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||
public bool CanEditStore(string storeId)
|
||||
{
|
||||
return true;
|
||||
}
|
173
BTCPayServer/Controllers/GreenField/UsersController.cs
Normal file
173
BTCPayServer/Controllers/GreenField/UsersController.cs
Normal file
@ -0,0 +1,173 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Configuration;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Events;
|
||||
using BTCPayServer.Logging;
|
||||
using BTCPayServer.Security;
|
||||
using BTCPayServer.Security.GreenField;
|
||||
using BTCPayServer.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
using NicolasDorier.RateLimits;
|
||||
using BTCPayServer.Client;
|
||||
|
||||
namespace BTCPayServer.Controllers.GreenField
|
||||
{
|
||||
[ApiController]
|
||||
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||
public class UsersController : ControllerBase
|
||||
{
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly BTCPayServerOptions _btcPayServerOptions;
|
||||
private readonly RoleManager<IdentityRole> _roleManager;
|
||||
private readonly SettingsRepository _settingsRepository;
|
||||
private readonly EventAggregator _eventAggregator;
|
||||
private readonly IPasswordValidator<ApplicationUser> _passwordValidator;
|
||||
private readonly RateLimitService _throttleService;
|
||||
private readonly BTCPayServerOptions _options;
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
|
||||
public UsersController(UserManager<ApplicationUser> userManager, BTCPayServerOptions btcPayServerOptions,
|
||||
RoleManager<IdentityRole> roleManager, SettingsRepository settingsRepository,
|
||||
EventAggregator eventAggregator,
|
||||
IPasswordValidator<ApplicationUser> passwordValidator,
|
||||
RateLimitService throttleService,
|
||||
BTCPayServerOptions options,
|
||||
IAuthorizationService authorizationService)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_btcPayServerOptions = btcPayServerOptions;
|
||||
_roleManager = roleManager;
|
||||
_settingsRepository = settingsRepository;
|
||||
_eventAggregator = eventAggregator;
|
||||
_passwordValidator = passwordValidator;
|
||||
_throttleService = throttleService;
|
||||
_options = options;
|
||||
_authorizationService = authorizationService;
|
||||
}
|
||||
|
||||
[Authorize(Policy = Policies.CanViewProfile, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||
[HttpGet("~/api/v1/users/me")]
|
||||
public async Task<ActionResult<ApplicationUserData>> GetCurrentUser()
|
||||
{
|
||||
var user = await _userManager.GetUserAsync(User);
|
||||
return FromModel(user);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("~/api/v1/users")]
|
||||
public async Task<ActionResult<ApplicationUserData>> CreateUser(CreateApplicationUserRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (request?.Email is null)
|
||||
return BadRequest(CreateValidationProblem(nameof(request.Email), "Email is missing"));
|
||||
if (!Validation.EmailValidator.IsEmail(request.Email))
|
||||
{
|
||||
return BadRequest(CreateValidationProblem(nameof(request.Email), "Invalid email"));
|
||||
}
|
||||
if (request?.Password is null)
|
||||
return BadRequest(CreateValidationProblem(nameof(request.Password), "Password is missing"));
|
||||
var anyAdmin = (await _userManager.GetUsersInRoleAsync(Roles.ServerAdmin)).Any();
|
||||
var policies = await _settingsRepository.GetSettingAsync<PoliciesSettings>() ?? new PoliciesSettings();
|
||||
var isAuth = User.Identity.AuthenticationType == GreenFieldConstants.AuthenticationType;
|
||||
|
||||
// If registration are locked and that an admin exists, don't accept unauthenticated connection
|
||||
if (anyAdmin && policies.LockSubscription && !isAuth)
|
||||
return Unauthorized();
|
||||
|
||||
// Even if subscription are unlocked, it is forbidden to create admin unauthenticated
|
||||
if (anyAdmin && request.IsAdministrator is true && !isAuth)
|
||||
return Forbid(AuthenticationSchemes.GreenfieldBasic);
|
||||
// You are de-facto admin if there is no other admin, else you need to be auth and pass policy requirements
|
||||
bool isAdmin = anyAdmin ? (await _authorizationService.AuthorizeAsync(User, null, new PolicyRequirement(Policies.CanModifyServerSettings))).Succeeded
|
||||
&& (await _authorizationService.AuthorizeAsync(User, null, new PolicyRequirement(Policies.Unrestricted))).Succeeded
|
||||
&& isAuth
|
||||
: true;
|
||||
// You need to be admin to create an admin
|
||||
if (request.IsAdministrator is true && !isAdmin)
|
||||
return Forbid(AuthenticationSchemes.GreenfieldBasic);
|
||||
|
||||
if (!isAdmin && policies.LockSubscription)
|
||||
{
|
||||
// If we are not admin and subscriptions are locked, we need to check the Policies.CanCreateUser.Key permission
|
||||
var canCreateUser = (await _authorizationService.AuthorizeAsync(User, null, new PolicyRequirement(Policies.CanCreateUser))).Succeeded;
|
||||
if (!isAuth || !canCreateUser)
|
||||
return Forbid(AuthenticationSchemes.GreenfieldBasic);
|
||||
}
|
||||
|
||||
var user = new ApplicationUser
|
||||
{
|
||||
UserName = request.Email,
|
||||
Email = request.Email,
|
||||
RequiresEmailConfirmation = policies.RequiresConfirmedEmail
|
||||
};
|
||||
var passwordValidation = await this._passwordValidator.ValidateAsync(_userManager, user, request.Password);
|
||||
if (!passwordValidation.Succeeded)
|
||||
{
|
||||
foreach (var error in passwordValidation.Errors)
|
||||
{
|
||||
ModelState.AddModelError(nameof(request.Password), error.Description);
|
||||
}
|
||||
return BadRequest(new ValidationProblemDetails(ModelState));
|
||||
}
|
||||
if (!isAdmin)
|
||||
{
|
||||
if (!await _throttleService.Throttle(ZoneLimits.Register, this.HttpContext.Connection.RemoteIpAddress, cancellationToken))
|
||||
return new TooManyRequestsResult(ZoneLimits.Register);
|
||||
}
|
||||
var identityResult = await _userManager.CreateAsync(user, request.Password);
|
||||
if (!identityResult.Succeeded)
|
||||
{
|
||||
foreach (var error in identityResult.Errors)
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, error.Description);
|
||||
}
|
||||
return BadRequest(new ValidationProblemDetails(ModelState));
|
||||
}
|
||||
|
||||
if (request.IsAdministrator is true)
|
||||
{
|
||||
if (!anyAdmin)
|
||||
{
|
||||
await _roleManager.CreateAsync(new IdentityRole(Roles.ServerAdmin));
|
||||
}
|
||||
await _userManager.AddToRoleAsync(user, Roles.ServerAdmin);
|
||||
if (!anyAdmin)
|
||||
{
|
||||
if (_options.DisableRegistration)
|
||||
{
|
||||
// automatically lock subscriptions now that we have our first admin
|
||||
Logs.PayServer.LogInformation("First admin created, disabling subscription (disable-registration is set to true)");
|
||||
policies.LockSubscription = true;
|
||||
await _settingsRepository.UpdateSetting(policies);
|
||||
}
|
||||
}
|
||||
}
|
||||
_eventAggregator.Publish(new UserRegisteredEvent() {RequestUri = Request.GetAbsoluteRootUri(), User = user, Admin = request.IsAdministrator is true });
|
||||
return CreatedAtAction(string.Empty, user);
|
||||
}
|
||||
|
||||
private ValidationProblemDetails CreateValidationProblem(string propertyName, string errorMessage)
|
||||
{
|
||||
var modelState = new ModelStateDictionary();
|
||||
modelState.AddModelError(propertyName, errorMessage);
|
||||
return new ValidationProblemDetails(modelState);
|
||||
}
|
||||
|
||||
private static ApplicationUserData FromModel(ApplicationUser data)
|
||||
{
|
||||
return new ApplicationUserData()
|
||||
{
|
||||
Id = data.Id,
|
||||
Email = data.Email,
|
||||
EmailConfirmed = data.EmailConfirmed,
|
||||
RequiresEmailConfirmation = data.RequiresEmailConfirmation
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
@ -14,22 +14,30 @@ using BTCPayServer.HostedServices;
|
||||
using BTCPayServer.Services.Apps;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using BTCPayServer.Data;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using System.IO;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using BTCPayServer.Security;
|
||||
|
||||
namespace BTCPayServer.Controllers
|
||||
{
|
||||
public class HomeController : Controller
|
||||
{
|
||||
private readonly CssThemeManager _cachedServerSettings;
|
||||
private readonly IFileProvider _fileProvider;
|
||||
|
||||
public IHttpClientFactory HttpClientFactory { get; }
|
||||
SignInManager<ApplicationUser> SignInManager { get; }
|
||||
|
||||
public HomeController(IHttpClientFactory httpClientFactory,
|
||||
CssThemeManager cachedServerSettings,
|
||||
IWebHostEnvironment webHostEnvironment,
|
||||
SignInManager<ApplicationUser> signInManager)
|
||||
{
|
||||
HttpClientFactory = httpClientFactory;
|
||||
_cachedServerSettings = cachedServerSettings;
|
||||
_fileProvider = webHostEnvironment.WebRootFileProvider;
|
||||
SignInManager = signInManager;
|
||||
}
|
||||
|
||||
@ -105,6 +113,30 @@ namespace BTCPayServer.Controllers
|
||||
return View(new BitpayTranslatorViewModel());
|
||||
}
|
||||
|
||||
[Route("swagger/v1/swagger.json")]
|
||||
public async Task<IActionResult> Swagger()
|
||||
{
|
||||
JObject json = new JObject();
|
||||
var directoryContents = _fileProvider.GetDirectoryContents("swagger/v1");
|
||||
foreach (IFileInfo fi in directoryContents)
|
||||
{
|
||||
await using var stream = fi.CreateReadStream();
|
||||
using var reader = new StreamReader(fi.CreateReadStream());
|
||||
json.Merge(JObject.Parse(await reader.ReadToEndAsync()));
|
||||
}
|
||||
var servers = new JArray();
|
||||
servers.Add(new JObject(new JProperty("url", HttpContext.Request.GetAbsoluteRoot())));
|
||||
json["servers"] = servers;
|
||||
return Json(json);
|
||||
}
|
||||
|
||||
[Route("docs")]
|
||||
public IActionResult SwaggerDocs()
|
||||
{
|
||||
return View();
|
||||
}
|
||||
|
||||
|
||||
[HttpPost]
|
||||
[Route("translate")]
|
||||
public async Task<IActionResult> BitpayTranslator(BitpayTranslatorViewModel vm)
|
||||
|
@ -2,6 +2,7 @@
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Filters;
|
||||
using BTCPayServer.Models;
|
||||
using BTCPayServer.Security;
|
||||
@ -12,7 +13,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
namespace BTCPayServer.Controllers
|
||||
{
|
||||
[BitpayAPIConstraint]
|
||||
[Authorize(Policies.CanCreateInvoice.Key, AuthenticationSchemes = AuthenticationSchemes.Bitpay)]
|
||||
[Authorize(Policies.CanCreateInvoice, AuthenticationSchemes = AuthenticationSchemes.Bitpay)]
|
||||
public class InvoiceControllerAPI : Controller
|
||||
{
|
||||
private InvoiceController _InvoiceController;
|
||||
|
@ -6,6 +6,7 @@ using System.Net.Mime;
|
||||
using System.Net.WebSockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Events;
|
||||
using BTCPayServer.Filters;
|
||||
@ -230,9 +231,9 @@ namespace BTCPayServer.Controllers
|
||||
OrderId = invoice.OrderId,
|
||||
InvoiceId = invoice.Id,
|
||||
DefaultLang = storeBlob.DefaultLang ?? "en",
|
||||
HtmlTitle = storeBlob.HtmlTitle ?? "BTCPay Invoice",
|
||||
CustomCSSLink = storeBlob.CustomCSS,
|
||||
CustomLogoLink = storeBlob.CustomLogo,
|
||||
HtmlTitle = storeBlob.HtmlTitle ?? "BTCPay Invoice",
|
||||
CryptoImage = Request.GetRelativePathOrAbsolute(paymentMethodHandler.GetCryptoImage(paymentMethodId)),
|
||||
BtcAddress = paymentMethodDetails.GetPaymentDestination(),
|
||||
BtcDue = accounting.Due.ToString(),
|
||||
@ -510,7 +511,7 @@ namespace BTCPayServer.Controllers
|
||||
|
||||
[HttpPost]
|
||||
[Route("invoices/create")]
|
||||
[Authorize(Policy = Policies.CanCreateInvoice.Key, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
[Authorize(Policy = Policies.CanCreateInvoice, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
[BitpayAPIConstraint(false)]
|
||||
public async Task<IActionResult> CreateInvoice(CreateInvoiceModel model, CancellationToken cancellationToken)
|
||||
{
|
||||
|
@ -154,6 +154,7 @@ namespace BTCPayServer.Controllers
|
||||
var rateRules = storeBlob.GetRateRules(_NetworkProvider);
|
||||
var fetchingByCurrencyPair = _RateProvider.FetchRates(currencyPairsToFetch, rateRules, cancellationToken);
|
||||
var fetchingAll = WhenAllFetched(logs, fetchingByCurrencyPair);
|
||||
|
||||
var supportedPaymentMethods = store.GetSupportedPaymentMethods(_NetworkProvider)
|
||||
.Where(s => !excludeFilter.Match(s.PaymentId) && _paymentMethodHandlerDictionary.Support(s.PaymentId))
|
||||
.Select(c =>
|
||||
@ -179,7 +180,8 @@ namespace BTCPayServer.Controllers
|
||||
if (supported.Count == 0)
|
||||
{
|
||||
StringBuilder errors = new StringBuilder();
|
||||
errors.AppendLine("Warning: No wallet has been linked to your BTCPay Store. See the following link for more information on how to connect your store and wallet. (https://docs.btcpayserver.org/getting-started/connectwallet)");
|
||||
if (!store.GetSupportedPaymentMethods(_NetworkProvider).Any())
|
||||
errors.AppendLine("Warning: No wallet has been linked to your BTCPay Store. See the following link for more information on how to connect your store and wallet. (https://docs.btcpayserver.org/getting-started/connectwallet)");
|
||||
foreach (var error in logs.ToList())
|
||||
{
|
||||
errors.AppendLine(error.ToString());
|
||||
@ -257,7 +259,7 @@ namespace BTCPayServer.Controllers
|
||||
|
||||
using (logs.Measure($"{logPrefix} Payment method details creation"))
|
||||
{
|
||||
var paymentDetails = await handler.CreatePaymentMethodDetails(supportedPaymentMethod, paymentMethod, store, network, preparePayment);
|
||||
var paymentDetails = await handler.CreatePaymentMethodDetails(logs, supportedPaymentMethod, paymentMethod, store, network, preparePayment);
|
||||
paymentMethod.SetPaymentMethodDetails(paymentDetails);
|
||||
}
|
||||
|
||||
|
@ -162,23 +162,6 @@ namespace BTCPayServer.Controllers
|
||||
return View(model);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GenerateRecoveryCodesWarning()
|
||||
{
|
||||
var user = await _userManager.GetUserAsync(User);
|
||||
if (user == null)
|
||||
{
|
||||
throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
|
||||
}
|
||||
|
||||
if (!user.TwoFactorEnabled)
|
||||
{
|
||||
throw new ApplicationException($"Cannot generate recovery codes for user with ID '{user.Id}' because they do not have 2FA enabled.");
|
||||
}
|
||||
|
||||
return View(nameof(GenerateRecoveryCodesWarning));
|
||||
}
|
||||
|
||||
private string GenerateQrCodeUri(string email, string unformattedKey)
|
||||
{
|
||||
return string.Format(CultureInfo.InvariantCulture,
|
||||
|
@ -3,14 +3,16 @@ using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Hosting.OpenApi;
|
||||
using BTCPayServer.Models;
|
||||
using BTCPayServer.Security;
|
||||
using BTCPayServer.Security.APIKeys;
|
||||
using BTCPayServer.Security.GreenField;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NSwag.Annotations;
|
||||
using NBitcoin;
|
||||
using NBitcoin.DataEncoders;
|
||||
using YamlDotNet.Core.Tokens;
|
||||
|
||||
namespace BTCPayServer.Controllers
|
||||
{
|
||||
@ -23,13 +25,11 @@ namespace BTCPayServer.Controllers
|
||||
{
|
||||
ApiKeyDatas = await _apiKeyRepository.GetKeys(new APIKeyRepository.APIKeyQuery()
|
||||
{
|
||||
UserId = new[] {_userManager.GetUserId(User)}
|
||||
UserId = new[] { _userManager.GetUserId(User) }
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
[HttpGet("api-keys/{id}/delete")]
|
||||
public async Task<IActionResult> RemoveAPIKey(string id)
|
||||
{
|
||||
@ -40,10 +40,10 @@ namespace BTCPayServer.Controllers
|
||||
}
|
||||
return View("Confirm", new ConfirmModel()
|
||||
{
|
||||
Title = "Delete API Key "+ ( string.IsNullOrEmpty(key.Label)? string.Empty: key.Label) + "("+key.Id+")",
|
||||
Title = "Delete API Key " + (string.IsNullOrEmpty(key.Label) ? string.Empty : key.Label) + "(" + key.Id + ")",
|
||||
Description = "Any application using this api key will immediately lose access",
|
||||
Action = "Delete",
|
||||
ActionUrl = Request.GetCurrentUrl().Replace("RemoveAPIKey", "RemoveAPIKeyPost")
|
||||
ActionUrl = this.Url.ActionLink(nameof(RemoveAPIKeyPost), values: new { id = id })
|
||||
});
|
||||
}
|
||||
|
||||
@ -80,15 +80,7 @@ namespace BTCPayServer.Controllers
|
||||
return View("AddApiKey", await SetViewModelValues(new AddApiKeyViewModel()));
|
||||
}
|
||||
|
||||
/// <param name="permissions">The permissions to request. Current permissions available: ServerManagement, StoreManagement</param>
|
||||
/// <param name="applicationName">The name of your application</param>
|
||||
/// <param name="strict">If permissions are specified, and strict is set to false, it will allow the user to reject some of permissions the application is requesting.</param>
|
||||
/// <param name="selectiveStores">If the application is requesting the CanModifyStoreSettings permission and selectiveStores is set to true, this allows the user to only grant permissions to selected stores under the user's control.</param>
|
||||
[HttpGet("~/api-keys/authorize")]
|
||||
[OpenApiTags("Authorization")]
|
||||
[OpenApiOperation("Authorize User",
|
||||
"Redirect the browser to this endpoint to request the user to generate an api-key with specific permissions")]
|
||||
[IncludeInOpenApiDocs]
|
||||
public async Task<IActionResult> AuthorizeAPIKey(string[] permissions, string applicationName = null,
|
||||
bool strict = true, bool selectiveStores = false)
|
||||
{
|
||||
@ -101,72 +93,93 @@ namespace BTCPayServer.Controllers
|
||||
});
|
||||
return RedirectToAction("APIKeys");
|
||||
}
|
||||
|
||||
|
||||
permissions ??= Array.Empty<string>();
|
||||
|
||||
var parsedPermissions = Permission.ToPermissions(permissions).GroupBy(permission => permission.Policy);
|
||||
var vm = await SetViewModelValues(new AuthorizeApiKeysViewModel()
|
||||
{
|
||||
Label = applicationName,
|
||||
ServerManagementPermission = permissions.Contains(APIKeyConstants.Permissions.ServerManagement),
|
||||
StoreManagementPermission = permissions.Contains(APIKeyConstants.Permissions.StoreManagement),
|
||||
PermissionsFormatted = permissions,
|
||||
ApplicationName = applicationName,
|
||||
SelectiveStores = selectiveStores,
|
||||
Strict = strict,
|
||||
Permissions = string.Join(';', parsedPermissions.SelectMany(grouping => grouping.Select(permission => permission.ToString())))
|
||||
});
|
||||
|
||||
vm.ServerManagementPermission = vm.ServerManagementPermission && vm.IsServerAdmin;
|
||||
AdjustVMForAuthorization(vm);
|
||||
|
||||
return View(vm);
|
||||
}
|
||||
|
||||
private void AdjustVMForAuthorization(AuthorizeApiKeysViewModel vm)
|
||||
{
|
||||
var parsedPermissions = Permission.ToPermissions(vm.Permissions.Split(';')).GroupBy(permission => permission.Policy);
|
||||
|
||||
for (var index = vm.PermissionValues.Count - 1; index >= 0; index--)
|
||||
{
|
||||
var permissionValue = vm.PermissionValues[index];
|
||||
var wanted = parsedPermissions?.SingleOrDefault(permission =>
|
||||
permission.Key.Equals(permissionValue.Permission,
|
||||
StringComparison.InvariantCultureIgnoreCase));
|
||||
if (vm.Strict && !(wanted?.Any()??false))
|
||||
{
|
||||
vm.PermissionValues.RemoveAt(index);
|
||||
continue;
|
||||
}
|
||||
else if (wanted?.Any()??false)
|
||||
{
|
||||
if (vm.SelectiveStores && Policies.IsStorePolicy(permissionValue.Permission) &&
|
||||
wanted.Any(permission => !string.IsNullOrEmpty(permission.StoreId)))
|
||||
{
|
||||
permissionValue.StoreMode = AddApiKeyViewModel.ApiKeyStoreMode.Specific;
|
||||
permissionValue.SpecificStores = wanted.Select(permission => permission.StoreId).ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
permissionValue.StoreMode = AddApiKeyViewModel.ApiKeyStoreMode.AllStores;
|
||||
permissionValue.SpecificStores = new List<string>();
|
||||
permissionValue.Value = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("~/api-keys/authorize")]
|
||||
public async Task<IActionResult> AuthorizeAPIKey([FromForm] AuthorizeApiKeysViewModel viewModel)
|
||||
{
|
||||
await SetViewModelValues(viewModel);
|
||||
|
||||
AdjustVMForAuthorization(viewModel);
|
||||
var ar = HandleCommands(viewModel);
|
||||
|
||||
|
||||
if (ar != null)
|
||||
{
|
||||
return ar;
|
||||
}
|
||||
|
||||
|
||||
if (viewModel.PermissionsFormatted.Contains(APIKeyConstants.Permissions.ServerManagement))
|
||||
|
||||
for (int i = 0; i < viewModel.PermissionValues.Count; i++)
|
||||
{
|
||||
if (!viewModel.IsServerAdmin && viewModel.ServerManagementPermission)
|
||||
if (viewModel.PermissionValues[i].Forbidden && viewModel.Strict)
|
||||
{
|
||||
viewModel.ServerManagementPermission = false;
|
||||
viewModel.PermissionValues[i].Value = false;
|
||||
ModelState.AddModelError($"{viewModel.PermissionValues}[{i}].Value",
|
||||
$"The permission '{viewModel.PermissionValues[i].Title}' is required for this application.");
|
||||
}
|
||||
|
||||
if (!viewModel.ServerManagementPermission && viewModel.Strict)
|
||||
if (viewModel.PermissionValues[i].StoreMode == AddApiKeyViewModel.ApiKeyStoreMode.Specific &&
|
||||
!viewModel.SelectiveStores)
|
||||
{
|
||||
ModelState.AddModelError(nameof(viewModel.ServerManagementPermission),
|
||||
"This permission is required for this application.");
|
||||
viewModel.PermissionValues[i].StoreMode = AddApiKeyViewModel.ApiKeyStoreMode.AllStores;
|
||||
ModelState.AddModelError($"{viewModel.PermissionValues}[{i}].Value",
|
||||
$"The permission '{viewModel.PermissionValues[i].Title}' cannot be store specific for this application.");
|
||||
}
|
||||
}
|
||||
|
||||
if (viewModel.PermissionsFormatted.Contains(APIKeyConstants.Permissions.StoreManagement))
|
||||
{
|
||||
if (!viewModel.SelectiveStores &&
|
||||
viewModel.StoreMode == AddApiKeyViewModel.ApiKeyStoreMode.Specific)
|
||||
{
|
||||
viewModel.StoreMode = AddApiKeyViewModel.ApiKeyStoreMode.AllStores;
|
||||
ModelState.AddModelError(nameof(viewModel.StoreManagementPermission),
|
||||
"This application does not allow selective store permissions.");
|
||||
}
|
||||
|
||||
if (!viewModel.StoreManagementPermission && !viewModel.SpecificStores.Any() && viewModel.Strict)
|
||||
{
|
||||
ModelState.AddModelError(nameof(viewModel.StoreManagementPermission),
|
||||
"This permission is required for this application.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return View(viewModel);
|
||||
}
|
||||
|
||||
|
||||
switch (viewModel.Command.ToLowerInvariant())
|
||||
{
|
||||
case "no":
|
||||
@ -176,10 +189,11 @@ namespace BTCPayServer.Controllers
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
{
|
||||
Severity = StatusMessageModel.StatusSeverity.Success,
|
||||
Html = $"API key generated! <code>{key.Id}</code>"
|
||||
Html = $"API key generated! <code class='alert-link'>{key.Id}</code>"
|
||||
});
|
||||
return RedirectToAction("APIKeys", new { key = key.Id});
|
||||
default: return View(viewModel);
|
||||
return RedirectToAction("APIKeys", new { key = key.Id });
|
||||
default:
|
||||
return View(viewModel);
|
||||
}
|
||||
}
|
||||
|
||||
@ -205,41 +219,54 @@ namespace BTCPayServer.Controllers
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
{
|
||||
Severity = StatusMessageModel.StatusSeverity.Success,
|
||||
Html = $"API key generated! <code>{key.Id}</code>"
|
||||
Html = $"API key generated! <code class='alert-link'>{key.Id}</code>"
|
||||
});
|
||||
return RedirectToAction("APIKeys");
|
||||
}
|
||||
private IActionResult HandleCommands(AddApiKeyViewModel viewModel)
|
||||
{
|
||||
switch (viewModel.Command)
|
||||
if (string.IsNullOrEmpty(viewModel.Command))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var parts = viewModel.Command.Split(':', StringSplitOptions.RemoveEmptyEntries);
|
||||
var permission = parts[0];
|
||||
if (!Policies.IsStorePolicy(permission))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var permissionValueItem = viewModel.PermissionValues.Single(item => item.Permission == permission);
|
||||
var command = parts[1];
|
||||
var storeIndex = parts.Length == 3 ? parts[2] : null;
|
||||
|
||||
ModelState.Clear();
|
||||
switch (command)
|
||||
{
|
||||
case "change-store-mode":
|
||||
viewModel.StoreMode = viewModel.StoreMode == AddApiKeyViewModel.ApiKeyStoreMode.Specific
|
||||
|
||||
permissionValueItem.StoreMode = permissionValueItem.StoreMode == AddApiKeyViewModel.ApiKeyStoreMode.Specific
|
||||
? AddApiKeyViewModel.ApiKeyStoreMode.AllStores
|
||||
: AddApiKeyViewModel.ApiKeyStoreMode.Specific;
|
||||
|
||||
if (viewModel.StoreMode == AddApiKeyViewModel.ApiKeyStoreMode.Specific &&
|
||||
!viewModel.SpecificStores.Any() && viewModel.Stores.Any())
|
||||
if (permissionValueItem.StoreMode == AddApiKeyViewModel.ApiKeyStoreMode.Specific &&
|
||||
!permissionValueItem.SpecificStores.Any() && viewModel.Stores.Any())
|
||||
{
|
||||
viewModel.SpecificStores.Add(null);
|
||||
permissionValueItem.SpecificStores.Add(null);
|
||||
}
|
||||
return View(viewModel);
|
||||
case "add-store":
|
||||
viewModel.SpecificStores.Add(null);
|
||||
permissionValueItem.SpecificStores.Add(null);
|
||||
return View(viewModel);
|
||||
|
||||
case string x when x.StartsWith("remove-store", StringComparison.InvariantCultureIgnoreCase):
|
||||
case "remove-store":
|
||||
{
|
||||
ModelState.Clear();
|
||||
var index = int.Parse(
|
||||
viewModel.Command.Substring(
|
||||
viewModel.Command.IndexOf(":", StringComparison.InvariantCultureIgnoreCase) + 1),
|
||||
CultureInfo.InvariantCulture);
|
||||
viewModel.SpecificStores.RemoveAt(index);
|
||||
if (storeIndex != null)
|
||||
permissionValueItem.SpecificStores.RemoveAt(int.Parse(storeIndex,
|
||||
CultureInfo.InvariantCulture));
|
||||
return View(viewModel);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -247,41 +274,65 @@ namespace BTCPayServer.Controllers
|
||||
{
|
||||
var key = new APIKeyData()
|
||||
{
|
||||
Id = Guid.NewGuid().ToString().Replace("-", string.Empty),
|
||||
Id = Encoders.Hex.EncodeData(RandomUtils.GetBytes(20)),
|
||||
Type = APIKeyType.Permanent,
|
||||
UserId = _userManager.GetUserId(User),
|
||||
Label = viewModel.Label
|
||||
};
|
||||
key.SetPermissions(GetPermissionsFromViewModel(viewModel));
|
||||
key.SetBlob(new APIKeyBlob()
|
||||
{
|
||||
Permissions = GetPermissionsFromViewModel(viewModel).Select(p => p.ToString()).Distinct().ToArray()
|
||||
});
|
||||
await _apiKeyRepository.CreateKey(key);
|
||||
return key;
|
||||
}
|
||||
|
||||
private IEnumerable<string> GetPermissionsFromViewModel(AddApiKeyViewModel viewModel)
|
||||
private IEnumerable<Permission> GetPermissionsFromViewModel(AddApiKeyViewModel viewModel)
|
||||
{
|
||||
var permissions = new List<string>();
|
||||
|
||||
if (viewModel.StoreMode == AddApiKeyViewModel.ApiKeyStoreMode.Specific)
|
||||
List<Permission> permissions = new List<Permission>();
|
||||
foreach (var p in viewModel.PermissionValues.Where(tuple => !tuple.Forbidden))
|
||||
{
|
||||
permissions.AddRange(viewModel.SpecificStores.Select(APIKeyConstants.Permissions.GetStorePermission));
|
||||
}
|
||||
else if (viewModel.StoreManagementPermission)
|
||||
{
|
||||
permissions.Add(APIKeyConstants.Permissions.StoreManagement);
|
||||
if (Policies.IsStorePolicy(p.Permission))
|
||||
{
|
||||
if (p.StoreMode == AddApiKeyViewModel.ApiKeyStoreMode.AllStores && p.Value)
|
||||
{
|
||||
permissions.Add(Permission.Create(p.Permission));
|
||||
}
|
||||
else if (p.StoreMode == AddApiKeyViewModel.ApiKeyStoreMode.Specific)
|
||||
{
|
||||
permissions.AddRange(p.SpecificStores.Select(s => Permission.Create(p.Permission, s)));
|
||||
}
|
||||
}
|
||||
else if (p.Value && Permission.TryCreatePermission(p.Permission, null, out var pp))
|
||||
permissions.Add(pp);
|
||||
}
|
||||
|
||||
if (viewModel.IsServerAdmin && viewModel.ServerManagementPermission)
|
||||
{
|
||||
permissions.Add(APIKeyConstants.Permissions.ServerManagement);
|
||||
}
|
||||
|
||||
return permissions;
|
||||
|
||||
return permissions.Distinct();
|
||||
}
|
||||
|
||||
private async Task<T> SetViewModelValues<T>(T viewModel) where T : AddApiKeyViewModel
|
||||
{
|
||||
viewModel.Stores = await _StoreRepository.GetStoresByUserId(_userManager.GetUserId(User));
|
||||
viewModel.IsServerAdmin = (await _authorizationService.AuthorizeAsync(User, Policies.CanModifyServerSettings.Key)).Succeeded;
|
||||
var isAdmin = (await _authorizationService.AuthorizeAsync(User, Policies.CanModifyServerSettings))
|
||||
.Succeeded;
|
||||
viewModel.PermissionValues ??= Policies.AllPolicies
|
||||
.Select(s => new AddApiKeyViewModel.PermissionValueItem()
|
||||
{
|
||||
Permission = s,
|
||||
Value = false,
|
||||
Forbidden = Policies.IsServerPolicy(s) && !isAdmin
|
||||
}).ToList();
|
||||
|
||||
|
||||
if (!isAdmin)
|
||||
{
|
||||
foreach (var p in viewModel.PermissionValues.Where(item => Policies.IsServerPolicy(item.Permission)))
|
||||
{
|
||||
p.Forbidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
return viewModel;
|
||||
}
|
||||
|
||||
@ -289,18 +340,52 @@ namespace BTCPayServer.Controllers
|
||||
{
|
||||
public string Label { get; set; }
|
||||
public StoreData[] Stores { get; set; }
|
||||
public ApiKeyStoreMode StoreMode { get; set; }
|
||||
public List<string> SpecificStores { get; set; } = new List<string>();
|
||||
public bool IsServerAdmin { get; set; }
|
||||
public bool ServerManagementPermission { get; set; }
|
||||
public bool StoreManagementPermission { get; set; }
|
||||
public string Command { get; set; }
|
||||
public List<PermissionValueItem> PermissionValues { get; set; }
|
||||
|
||||
public enum ApiKeyStoreMode
|
||||
{
|
||||
AllStores,
|
||||
Specific
|
||||
}
|
||||
|
||||
public class PermissionValueItem
|
||||
{
|
||||
public static readonly Dictionary<string, (string Title, string Description)> PermissionDescriptions = new Dictionary<string, (string Title, string Description)>()
|
||||
{
|
||||
{BTCPayServer.Client.Policies.Unrestricted, ("Unrestricted access", "The app will have unrestricted access to your account.")},
|
||||
{BTCPayServer.Client.Policies.CanCreateUser, ("Create new users", "The app will be able to create new users on this server.")},
|
||||
{BTCPayServer.Client.Policies.CanModifyStoreSettings, ("Modify your stores", "The app will be able to create, view and modify, delete and create new invoices on the all your stores.")},
|
||||
{$"{BTCPayServer.Client.Policies.CanModifyStoreSettings}:", ("Manage selected stores", "The app will be able to view, modify, delete and create new invoices on the selected stores.")},
|
||||
{BTCPayServer.Client.Policies.CanViewStoreSettings, ("View your stores", "The app will be able to view stores settings.")},
|
||||
{$"{BTCPayServer.Client.Policies.CanViewStoreSettings}:", ("View your stores", "The app will be able to view the selected stores' settings.")},
|
||||
{BTCPayServer.Client.Policies.CanModifyServerSettings, ("Manage your server", "The app will have total control on the server settings of your server")},
|
||||
{BTCPayServer.Client.Policies.CanViewProfile, ("View your profile", "The app will be able to view your user profile.")},
|
||||
{BTCPayServer.Client.Policies.CanModifyProfile, ("Manage your profile", "The app will be able to view and modify your user profile.")},
|
||||
{BTCPayServer.Client.Policies.CanCreateInvoice, ("Create an invoice", "The app will be able to create new invoices.")},
|
||||
{$"{BTCPayServer.Client.Policies.CanCreateInvoice}:", ("Create an invoice", "The app will be able to create new invoices on the selected stores.")},
|
||||
};
|
||||
public string Title
|
||||
{
|
||||
get
|
||||
{
|
||||
return PermissionDescriptions[$"{Permission}{(StoreMode == ApiKeyStoreMode.Specific? ":": "")}"].Title;
|
||||
}
|
||||
}
|
||||
public string Description
|
||||
{
|
||||
get
|
||||
{
|
||||
return PermissionDescriptions[$"{Permission}{(StoreMode == ApiKeyStoreMode.Specific? ":": "")}"].Description;
|
||||
}
|
||||
}
|
||||
public string Permission { get; set; }
|
||||
public bool Value { get; set; }
|
||||
public bool Forbidden { get; set; }
|
||||
|
||||
public ApiKeyStoreMode StoreMode { get; set; } = ApiKeyStoreMode.AllStores;
|
||||
public List<string> SpecificStores { get; set; } = new List<string>();
|
||||
}
|
||||
}
|
||||
|
||||
public class AuthorizeApiKeysViewModel : AddApiKeyViewModel
|
||||
@ -309,18 +394,6 @@ namespace BTCPayServer.Controllers
|
||||
public bool Strict { get; set; }
|
||||
public bool SelectiveStores { get; set; }
|
||||
public string Permissions { get; set; }
|
||||
|
||||
public string[] PermissionsFormatted
|
||||
{
|
||||
get
|
||||
{
|
||||
return Permissions?.Split(";", StringSplitOptions.RemoveEmptyEntries)?? Array.Empty<string>();
|
||||
}
|
||||
set
|
||||
{
|
||||
Permissions = string.Join(';', value ?? Array.Empty<string>());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
@ -19,8 +19,8 @@ using System.Globalization;
|
||||
using BTCPayServer.Security;
|
||||
using BTCPayServer.U2F;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Security.APIKeys;
|
||||
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using BTCPayServer.Security.GreenField;
|
||||
|
||||
namespace BTCPayServer.Controllers
|
||||
{
|
||||
@ -38,6 +38,7 @@ namespace BTCPayServer.Controllers
|
||||
private readonly BTCPayServerEnvironment _btcPayServerEnvironment;
|
||||
private readonly APIKeyRepository _apiKeyRepository;
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly LinkGenerator _linkGenerator;
|
||||
StoreRepository _StoreRepository;
|
||||
|
||||
|
||||
@ -54,7 +55,8 @@ namespace BTCPayServer.Controllers
|
||||
U2FService u2FService,
|
||||
BTCPayServerEnvironment btcPayServerEnvironment,
|
||||
APIKeyRepository apiKeyRepository,
|
||||
IAuthorizationService authorizationService
|
||||
IAuthorizationService authorizationService,
|
||||
LinkGenerator linkGenerator
|
||||
)
|
||||
{
|
||||
_userManager = userManager;
|
||||
@ -67,6 +69,7 @@ namespace BTCPayServer.Controllers
|
||||
_btcPayServerEnvironment = btcPayServerEnvironment;
|
||||
_apiKeyRepository = apiKeyRepository;
|
||||
_authorizationService = authorizationService;
|
||||
_linkGenerator = linkGenerator;
|
||||
_StoreRepository = storeRepository;
|
||||
}
|
||||
|
||||
@ -146,7 +149,7 @@ namespace BTCPayServer.Controllers
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return View(model);
|
||||
return View(nameof(Index), model);
|
||||
}
|
||||
|
||||
var user = await _userManager.GetUserAsync(User);
|
||||
@ -156,7 +159,7 @@ namespace BTCPayServer.Controllers
|
||||
}
|
||||
|
||||
var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
|
||||
var callbackUrl = Url.EmailConfirmationLink(user.Id, code, Request.Scheme);
|
||||
var callbackUrl = _linkGenerator.EmailConfirmationLink(user.Id, code, Request.Scheme, Request.Host, Request.PathBase);
|
||||
var email = user.Email;
|
||||
_EmailSenderFactory.GetEmailSender().SendEmailConfirmation(email, callbackUrl);
|
||||
TempData[WellKnownTempData.SuccessMessage] = "Verification email sent. Please check your email.";
|
||||
|
@ -98,7 +98,7 @@ namespace BTCPayServer.Controllers
|
||||
{
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
{
|
||||
Html = $"Error: You need to create at least one store. <a href='{Url.Action("CreateStore", "UserStores")}'>Create store</a>",
|
||||
Html = $"Error: You need to create at least one store. <a href='{Url.Action("CreateStore", "UserStores")}' class='alert-link'>Create store</a>",
|
||||
Severity = StatusMessageModel.StatusSeverity.Error
|
||||
});
|
||||
return RedirectToAction("GetPaymentRequests");
|
||||
@ -225,6 +225,10 @@ namespace BTCPayServer.Controllers
|
||||
public async Task<IActionResult> PayPaymentRequest(string id, bool redirectToInvoice = true,
|
||||
decimal? amount = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (amount.HasValue && amount.Value <= 0)
|
||||
{
|
||||
return BadRequest("Please provide an amount greater than 0");
|
||||
}
|
||||
var result = await _PaymentRequestService.GetPaymentRequest(id, GetUserId());
|
||||
if (result == null)
|
||||
{
|
||||
|
@ -20,7 +20,7 @@ using BTCPayServer.Security.Bitpay;
|
||||
namespace BTCPayServer.Controllers
|
||||
{
|
||||
[EnableCors(CorsPolicies.All)]
|
||||
[Authorize(Policy = Policies.CanGetRates.Key, AuthenticationSchemes = Security.AuthenticationSchemes.Bitpay)]
|
||||
[Authorize(Policy = ServerPolicies.CanGetRates.Key, AuthenticationSchemes = Security.AuthenticationSchemes.Bitpay)]
|
||||
public class RateController : Controller
|
||||
{
|
||||
public StoreData CurrentStore
|
||||
|
@ -1,23 +0,0 @@
|
||||
using BTCPayServer.Data;
|
||||
|
||||
namespace BTCPayServer.Controllers.RestApi.ApiKeys
|
||||
{
|
||||
public class ApiKeyData
|
||||
{
|
||||
public string ApiKey { get; set; }
|
||||
public string Label { get; set; }
|
||||
public string UserId { get; set; }
|
||||
public string[] Permissions { get; set; }
|
||||
|
||||
public static ApiKeyData FromModel(APIKeyData data)
|
||||
{
|
||||
return new ApiKeyData()
|
||||
{
|
||||
Permissions = data.GetPermissions(),
|
||||
ApiKey = data.Id,
|
||||
UserId = data.UserId,
|
||||
Label = data.Label
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
@ -1,37 +0,0 @@
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Hosting.OpenApi;
|
||||
using BTCPayServer.Security;
|
||||
using BTCPayServer.Security.APIKeys;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NSwag.Annotations;
|
||||
|
||||
namespace BTCPayServer.Controllers.RestApi.ApiKeys
|
||||
{
|
||||
[ApiController]
|
||||
[IncludeInOpenApiDocs]
|
||||
[OpenApiTags("API Keys")]
|
||||
[Authorize(AuthenticationSchemes = AuthenticationSchemes.ApiKey)]
|
||||
public class ApiKeysController : ControllerBase
|
||||
{
|
||||
private readonly APIKeyRepository _apiKeyRepository;
|
||||
|
||||
public ApiKeysController(APIKeyRepository apiKeyRepository)
|
||||
{
|
||||
_apiKeyRepository = apiKeyRepository;
|
||||
}
|
||||
|
||||
[OpenApiOperation("Get current API Key information", "View information about the current API key")]
|
||||
[SwaggerResponse(StatusCodes.Status200OK, typeof(ApiKeyData),
|
||||
Description = "Information about the current api key")]
|
||||
[HttpGet("~/api/v1/api-keys/current")]
|
||||
[HttpGet("~/api/v1/users/me/api-keys/current")]
|
||||
public async Task<ActionResult<ApiKeyData>> GetKey()
|
||||
{
|
||||
ControllerContext.HttpContext.GetAPIKey(out var apiKey);
|
||||
var data = await _apiKeyRepository.GetKey(apiKey);
|
||||
return Ok(ApiKeyData.FromModel(data));
|
||||
}
|
||||
}
|
||||
}
|
@ -35,10 +35,11 @@ using BTCPayServer.Services.Apps;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using BTCPayServer.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using BTCPayServer.Client;
|
||||
|
||||
namespace BTCPayServer.Controllers
|
||||
{
|
||||
[Authorize(Policy = BTCPayServer.Security.Policies.CanModifyServerSettings.Key,
|
||||
[Authorize(Policy = BTCPayServer.Client.Policies.CanModifyServerSettings,
|
||||
AuthenticationSchemes = BTCPayServer.Security.AuthenticationSchemes.Cookie)]
|
||||
public partial class ServerController : Controller
|
||||
{
|
||||
@ -347,6 +348,14 @@ namespace BTCPayServer.Controllers
|
||||
var user = userId == null ? null : await _UserManager.FindByIdAsync(userId);
|
||||
if (user == null)
|
||||
return NotFound();
|
||||
|
||||
var files = await _StoredFileRepository.GetFiles(new StoredFileRepository.FilesQuery()
|
||||
{
|
||||
UserIds = new[] {userId},
|
||||
});
|
||||
|
||||
await Task.WhenAll(files.Select(file => _FileService.RemoveFile(file.Id, userId)));
|
||||
|
||||
await _UserManager.DeleteAsync(user);
|
||||
await _StoreRepository.CleanUnreachableStores();
|
||||
TempData[WellKnownTempData.SuccessMessage] = "User deleted";
|
||||
|
@ -5,6 +5,7 @@ using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.WebSockets;
|
||||
using System.Reflection.Metadata.Ecma335;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@ -22,6 +23,9 @@ using NBXplorer.DerivationStrategy;
|
||||
using NBXplorer.Models;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using BTCPayServer.Logging;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using BTCPayServer.Client;
|
||||
|
||||
namespace BTCPayServer.Controllers
|
||||
{
|
||||
@ -51,7 +55,9 @@ namespace BTCPayServer.Controllers
|
||||
vm.Config = derivation.ToJson();
|
||||
}
|
||||
vm.Enabled = !store.GetStoreBlob().IsExcluded(new PaymentMethodId(vm.CryptoCode, PaymentTypes.BTCLike));
|
||||
vm.CanUseHotWallet = await CanUseHotWallet();
|
||||
var hotWallet = await CanUseHotWallet();
|
||||
vm.CanUseHotWallet = hotWallet.HotWallet;
|
||||
vm.CanUseRPCImport = hotWallet.RPCImport;
|
||||
return View(vm);
|
||||
}
|
||||
|
||||
@ -328,11 +334,15 @@ namespace BTCPayServer.Controllers
|
||||
public async Task<IActionResult> GenerateNBXWallet(string storeId, string cryptoCode,
|
||||
GenerateWalletRequest request)
|
||||
{
|
||||
if (!await CanUseHotWallet())
|
||||
Logs.Events.LogInformation($"GenerateNBXWallet called {storeId}, {cryptoCode}");
|
||||
var hotWallet = await CanUseHotWallet();
|
||||
if (!hotWallet.HotWallet || (!hotWallet.RPCImport && request.ImportKeysToRPC))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
|
||||
Logs.Events.LogInformation($"GenerateNBXWallet after CanUseHotWallet");
|
||||
|
||||
var network = _NetworkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
|
||||
var client = _ExplorerProvider.GetExplorerClient(cryptoCode);
|
||||
var response = await client.GenerateWalletAsync(request);
|
||||
@ -343,13 +353,16 @@ namespace BTCPayServer.Controllers
|
||||
Severity = StatusMessageModel.StatusSeverity.Error,
|
||||
Html = "There was an error generating your wallet. Is your node available?"
|
||||
});
|
||||
return RedirectToAction("AddDerivationScheme", new {storeId, cryptoCode});
|
||||
return RedirectToAction(nameof(AddDerivationScheme), new {storeId, cryptoCode});
|
||||
}
|
||||
|
||||
Logs.Events.LogInformation($"GenerateNBXWallet after GenerateWalletAsync");
|
||||
|
||||
var store = HttpContext.GetStoreData();
|
||||
var result = await AddDerivationScheme(storeId,
|
||||
new DerivationSchemeViewModel()
|
||||
{
|
||||
Confirmation = false,
|
||||
Confirmation = string.IsNullOrEmpty(request.ExistingMnemonic),
|
||||
Network = network,
|
||||
RootFingerprint = response.AccountKeyPath.MasterFingerprint.ToString(),
|
||||
RootKeyPath = network.GetRootKeyPath(),
|
||||
@ -362,24 +375,37 @@ namespace BTCPayServer.Controllers
|
||||
Enabled = !store.GetStoreBlob()
|
||||
.IsExcluded(new PaymentMethodId(cryptoCode, PaymentTypes.BTCLike))
|
||||
}, cryptoCode);
|
||||
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
if (!ModelState.IsValid || !(result is RedirectToActionResult))
|
||||
return result;
|
||||
TempData.Clear();
|
||||
if (string.IsNullOrEmpty(request.ExistingMnemonic))
|
||||
{
|
||||
Severity = StatusMessageModel.StatusSeverity.Success,
|
||||
Html = !string.IsNullOrEmpty(request.ExistingMnemonic)
|
||||
? "Your wallet has been imported."
|
||||
: $"Your wallet has been generated. Please store your seed securely! <br/><code>{response.Mnemonic}</code>"
|
||||
});
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
{
|
||||
Severity = StatusMessageModel.StatusSeverity.Success,
|
||||
Html = $"Your wallet has been generated. Please store your seed securely! <br/><code class=\"alert-link\">{response.Mnemonic}</code>"
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
{
|
||||
Severity = StatusMessageModel.StatusSeverity.Warning,
|
||||
Html = "Please check your addresses and confirm"
|
||||
});
|
||||
}
|
||||
Logs.Events.LogInformation($"GenerateNBXWallet returning success result");
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<bool> CanUseHotWallet()
|
||||
private async Task<(bool HotWallet, bool RPCImport)> CanUseHotWallet()
|
||||
{
|
||||
var isAdmin = (await _authorizationService.AuthorizeAsync(User, BTCPayServer.Security.Policies.CanModifyServerSettings.Key)).Succeeded;
|
||||
var isAdmin = (await _authorizationService.AuthorizeAsync(User, Policies.CanModifyServerSettings)).Succeeded;
|
||||
if (isAdmin)
|
||||
return true;
|
||||
return (true, true);
|
||||
var policies = await _settingsRepository.GetSettingAsync<PoliciesSettings>();
|
||||
return policies?.AllowHotWalletForAll is true;
|
||||
var hotWallet = policies?.AllowHotWalletForAll is true;
|
||||
return (hotWallet, hotWallet && policies?.AllowHotWalletRPCImportForAll is true);
|
||||
}
|
||||
|
||||
private async Task<string> ReadAllText(IFormFile file)
|
||||
|
@ -5,6 +5,7 @@ using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Configuration;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.HostedServices;
|
||||
@ -17,6 +18,7 @@ using BTCPayServer.Rating;
|
||||
using BTCPayServer.Security;
|
||||
using BTCPayServer.Security.Bitpay;
|
||||
using BTCPayServer.Services;
|
||||
using BTCPayServer.Services.Apps;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using BTCPayServer.Services.Rates;
|
||||
using BTCPayServer.Services.Stores;
|
||||
@ -33,7 +35,7 @@ namespace BTCPayServer.Controllers
|
||||
{
|
||||
[Route("stores")]
|
||||
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
[Authorize(Policy = Policies.CanModifyStoreSettings.Key, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
[AutoValidateAntiforgeryToken]
|
||||
public partial class StoresController : Controller
|
||||
{
|
||||
@ -59,7 +61,8 @@ namespace BTCPayServer.Controllers
|
||||
SettingsRepository settingsRepository,
|
||||
IAuthorizationService authorizationService,
|
||||
EventAggregator eventAggregator,
|
||||
CssThemeManager cssThemeManager)
|
||||
CssThemeManager cssThemeManager,
|
||||
AppService appService)
|
||||
{
|
||||
_RateFactory = rateFactory;
|
||||
_Repo = repo;
|
||||
@ -75,6 +78,7 @@ namespace BTCPayServer.Controllers
|
||||
_settingsRepository = settingsRepository;
|
||||
_authorizationService = authorizationService;
|
||||
_CssThemeManager = cssThemeManager;
|
||||
_appService = appService;
|
||||
_EventAggregator = eventAggregator;
|
||||
_NetworkProvider = networkProvider;
|
||||
_ExplorerProvider = explorerProvider;
|
||||
@ -102,6 +106,7 @@ namespace BTCPayServer.Controllers
|
||||
private readonly SettingsRepository _settingsRepository;
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly CssThemeManager _CssThemeManager;
|
||||
private readonly AppService _appService;
|
||||
private readonly EventAggregator _EventAggregator;
|
||||
|
||||
[TempData]
|
||||
@ -474,6 +479,7 @@ namespace BTCPayServer.Controllers
|
||||
vm.InvoiceExpiration = storeBlob.InvoiceExpiration;
|
||||
vm.LightningDescriptionTemplate = storeBlob.LightningDescriptionTemplate;
|
||||
vm.PaymentTolerance = storeBlob.PaymentTolerance;
|
||||
vm.PayJoinEnabled = storeBlob.PayJoinEnabled;
|
||||
return View(vm);
|
||||
}
|
||||
|
||||
@ -568,7 +574,7 @@ namespace BTCPayServer.Controllers
|
||||
blob.InvoiceExpiration = model.InvoiceExpiration;
|
||||
blob.LightningDescriptionTemplate = model.LightningDescriptionTemplate ?? string.Empty;
|
||||
blob.PaymentTolerance = model.PaymentTolerance;
|
||||
|
||||
blob.PayJoinEnabled = model.PayJoinEnabled;
|
||||
if (CurrentStore.SetStoreBlob(blob))
|
||||
{
|
||||
needUpdate = true;
|
||||
@ -888,7 +894,7 @@ namespace BTCPayServer.Controllers
|
||||
const string DEFAULT_CURRENCY = "USD";
|
||||
|
||||
[Route("{storeId}/paybutton")]
|
||||
public IActionResult PayButton()
|
||||
public async Task<IActionResult> PayButton()
|
||||
{
|
||||
var store = CurrentStore;
|
||||
|
||||
@ -898,6 +904,7 @@ namespace BTCPayServer.Controllers
|
||||
return View("PayButtonEnable", null);
|
||||
}
|
||||
|
||||
var apps = await _appService.GetAllApps(_UserManager.GetUserId(User), false, store.Id);
|
||||
var appUrl = HttpContext.Request.GetAbsoluteRoot().WithTrailingSlash();
|
||||
var model = new PayButtonViewModel
|
||||
{
|
||||
@ -910,7 +917,8 @@ namespace BTCPayServer.Controllers
|
||||
ButtonType = 0,
|
||||
Min = 1,
|
||||
Max = 20,
|
||||
Step = 1
|
||||
Step = 1,
|
||||
Apps = apps
|
||||
};
|
||||
return View(model);
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Hwi;
|
||||
using BTCPayServer.ModelBinders;
|
||||
@ -127,7 +128,7 @@ namespace BTCPayServer.Controllers
|
||||
}
|
||||
await websocketHelper.Send("{ \"info\": \"ready\"}", cancellationToken);
|
||||
o = JObject.Parse(await websocketHelper.NextMessageAsync(cancellationToken));
|
||||
var authorization = await _authorizationService.AuthorizeAsync(User, Policies.CanModifyStoreSettings.Key);
|
||||
var authorization = await _authorizationService.AuthorizeAsync(User, Policies.CanModifyStoreSettings);
|
||||
if (!authorization.Succeeded)
|
||||
{
|
||||
await websocketHelper.Send("{ \"error\": \"not-authorized\"}", cancellationToken);
|
||||
|
@ -1,10 +1,14 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.ModelBinders;
|
||||
using BTCPayServer.Models;
|
||||
using BTCPayServer.Models.WalletViewModels;
|
||||
using BTCPayServer.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NBitcoin;
|
||||
using NBXplorer;
|
||||
@ -20,6 +24,10 @@ namespace BTCPayServer.Controllers
|
||||
{
|
||||
var nbx = ExplorerClientProvider.GetExplorerClient(network);
|
||||
CreatePSBTRequest psbtRequest = new CreatePSBTRequest();
|
||||
if (sendModel.InputSelection)
|
||||
{
|
||||
psbtRequest.IncludeOnlyOutpoints = sendModel.SelectedInputs?.Select(OutPoint.Parse)?.ToList()?? new List<OutPoint>();
|
||||
}
|
||||
foreach (var transactionOutput in sendModel.Outputs)
|
||||
{
|
||||
var psbtDestination = new CreatePSBTDestination();
|
||||
@ -64,6 +72,7 @@ namespace BTCPayServer.Controllers
|
||||
vm.Decoded = psbt.ToString();
|
||||
vm.PSBT = psbt.ToBase64();
|
||||
}
|
||||
|
||||
return View(nameof(WalletPSBT), vm ?? new WalletPSBTViewModel() { CryptoCode = walletId.CryptoCode });
|
||||
}
|
||||
[HttpPost]
|
||||
@ -94,12 +103,12 @@ namespace BTCPayServer.Controllers
|
||||
vm.FileName = vm.UploadedPSBTFile?.FileName;
|
||||
return View(vm);
|
||||
case "vault":
|
||||
return ViewVault(walletId, psbt);
|
||||
return ViewVault(walletId, psbt, vm.PayJoinEndpointUrl);
|
||||
case "ledger":
|
||||
return ViewWalletSendLedger(walletId, psbt);
|
||||
case "update":
|
||||
var derivationSchemeSettings = GetDerivationSchemeSettings(walletId);
|
||||
psbt = await UpdatePSBT(derivationSchemeSettings, psbt, network);
|
||||
psbt = await ExplorerClientProvider.UpdatePSBT(derivationSchemeSettings, psbt);
|
||||
if (psbt == null)
|
||||
{
|
||||
ModelState.AddModelError(nameof(vm.PSBT), "You need to update your version of NBXplorer");
|
||||
@ -108,7 +117,7 @@ namespace BTCPayServer.Controllers
|
||||
TempData[WellKnownTempData.SuccessMessage] = "PSBT updated!";
|
||||
return RedirectToWalletPSBT(psbt, vm.FileName);
|
||||
case "seed":
|
||||
return SignWithSeed(walletId, psbt.ToBase64());
|
||||
return SignWithSeed(walletId, psbt.ToBase64(), vm.PayJoinEndpointUrl);
|
||||
case "nbx-seed":
|
||||
if (await CanUseHotWallet())
|
||||
{
|
||||
@ -118,7 +127,7 @@ namespace BTCPayServer.Controllers
|
||||
WellknownMetadataKeys.MasterHDKey);
|
||||
|
||||
return SignWithSeed(walletId,
|
||||
new SignWithSeedViewModel() {SeedOrKey = extKey, PSBT = psbt.ToBase64()});
|
||||
new SignWithSeedViewModel() {SeedOrKey = extKey, PSBT = psbt.ToBase64(), PayJoinEndpointUrl = vm.PayJoinEndpointUrl});
|
||||
}
|
||||
|
||||
return View(vm);
|
||||
@ -136,31 +145,32 @@ namespace BTCPayServer.Controllers
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<PSBT> UpdatePSBT(DerivationSchemeSettings derivationSchemeSettings, PSBT psbt, BTCPayNetwork network)
|
||||
private async Task<PSBT> GetPayjoinProposedTX(string bpu, PSBT psbt, DerivationSchemeSettings derivationSchemeSettings, BTCPayNetwork btcPayNetwork, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await ExplorerClientProvider.GetExplorerClient(network).UpdatePSBTAsync(new UpdatePSBTRequest()
|
||||
{
|
||||
PSBT = psbt,
|
||||
DerivationScheme = derivationSchemeSettings.AccountDerivation,
|
||||
});
|
||||
if (result == null)
|
||||
return null;
|
||||
derivationSchemeSettings.RebaseKeyPaths(result.PSBT);
|
||||
return result.PSBT;
|
||||
if (string.IsNullOrEmpty(bpu) || !Uri.TryCreate(bpu, UriKind.Absolute, out var endpoint))
|
||||
throw new InvalidOperationException("No payjoin url available");
|
||||
var cloned = psbt.Clone();
|
||||
cloned = cloned.Finalize();
|
||||
await _broadcaster.Schedule(DateTimeOffset.UtcNow + TimeSpan.FromMinutes(1.0), cloned.ExtractTransaction(), btcPayNetwork);
|
||||
return await _payjoinClient.RequestPayjoin(endpoint, derivationSchemeSettings, psbt, cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
[HttpGet]
|
||||
[Route("{walletId}/psbt/ready")]
|
||||
public async Task<IActionResult> WalletPSBTReady(
|
||||
[ModelBinder(typeof(WalletIdModelBinder))]
|
||||
WalletId walletId, string psbt = null,
|
||||
string signingKey = null,
|
||||
string signingKeyPath = null)
|
||||
string signingKeyPath = null,
|
||||
string originalPsbt = null,
|
||||
string payJoinEndpointUrl = null)
|
||||
{
|
||||
var network = NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
|
||||
var vm = new WalletPSBTReadyViewModel() { PSBT = psbt };
|
||||
vm.SigningKey = signingKey;
|
||||
vm.SigningKeyPath = signingKeyPath;
|
||||
vm.OriginalPSBT = originalPsbt;
|
||||
vm.PayJoinEndpointUrl = payJoinEndpointUrl;
|
||||
var derivationSchemeSettings = GetDerivationSchemeSettings(walletId);
|
||||
if (derivationSchemeSettings == null)
|
||||
return NotFound();
|
||||
@ -176,7 +186,7 @@ namespace BTCPayServer.Controllers
|
||||
{
|
||||
var psbtObject = PSBT.Parse(vm.PSBT, network.NBitcoinNetwork);
|
||||
if (!psbtObject.IsAllFinalized())
|
||||
psbtObject = await UpdatePSBT(derivationSchemeSettings, psbtObject, network) ?? psbtObject;
|
||||
psbtObject = await ExplorerClientProvider.UpdatePSBT(derivationSchemeSettings, psbtObject) ?? psbtObject;
|
||||
IHDKey signingKey = null;
|
||||
RootedKeyPath signingKeyPath = null;
|
||||
try
|
||||
@ -278,16 +288,17 @@ namespace BTCPayServer.Controllers
|
||||
[Route("{walletId}/psbt/ready")]
|
||||
public async Task<IActionResult> WalletPSBTReady(
|
||||
[ModelBinder(typeof(WalletIdModelBinder))]
|
||||
WalletId walletId, WalletPSBTReadyViewModel vm, string command = null)
|
||||
WalletId walletId, WalletPSBTReadyViewModel vm, string command = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (command == null)
|
||||
return await WalletPSBTReady(walletId, vm.PSBT, vm.SigningKey, vm.SigningKeyPath);
|
||||
return await WalletPSBTReady(walletId, vm.PSBT, vm.SigningKey, vm.SigningKeyPath, vm.OriginalPSBT, vm.PayJoinEndpointUrl);
|
||||
PSBT psbt = null;
|
||||
var network = NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
|
||||
DerivationSchemeSettings derivationSchemeSettings = null;
|
||||
try
|
||||
{
|
||||
psbt = PSBT.Parse(vm.PSBT, network.NBitcoinNetwork);
|
||||
var derivationSchemeSettings = GetDerivationSchemeSettings(walletId);
|
||||
derivationSchemeSettings = GetDerivationSchemeSettings(walletId);
|
||||
if (derivationSchemeSettings == null)
|
||||
return NotFound();
|
||||
await FetchTransactionDetails(derivationSchemeSettings, vm, network);
|
||||
@ -297,38 +308,115 @@ namespace BTCPayServer.Controllers
|
||||
vm.GlobalError = "Invalid PSBT";
|
||||
return View(nameof(WalletPSBTReady),vm);
|
||||
}
|
||||
if (command == "broadcast")
|
||||
|
||||
switch (command)
|
||||
{
|
||||
if (!psbt.IsAllFinalized() && !psbt.TryFinalize(out var errors))
|
||||
{
|
||||
case "payjoin":
|
||||
string error = null;
|
||||
try
|
||||
{
|
||||
var proposedPayjoin = await GetPayjoinProposedTX(vm.PayJoinEndpointUrl, psbt,
|
||||
derivationSchemeSettings, network, cancellationToken);
|
||||
try
|
||||
{
|
||||
var extKey = ExtKey.Parse(vm.SigningKey, network.NBitcoinNetwork);
|
||||
proposedPayjoin = proposedPayjoin.SignAll(derivationSchemeSettings.AccountDerivation,
|
||||
extKey,
|
||||
RootedKeyPath.Parse(vm.SigningKeyPath));
|
||||
vm.PSBT = proposedPayjoin.ToBase64();
|
||||
vm.OriginalPSBT = psbt.ToBase64();
|
||||
proposedPayjoin.Finalize();
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
{
|
||||
Severity = StatusMessageModel.StatusSeverity.Success,
|
||||
AllowDismiss = false,
|
||||
Html = $"The payjoin transaction has been successfully broadcasted ({proposedPayjoin.ExtractTransaction().GetHash()})"
|
||||
});
|
||||
return await WalletPSBTReady(walletId, vm, "broadcast");
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
{
|
||||
Severity = StatusMessageModel.StatusSeverity.Warning,
|
||||
AllowDismiss = false,
|
||||
Html =
|
||||
$"This transaction has been coordinated between the receiver and you to create a <a href='https://en.bitcoin.it/wiki/PayJoin' target='_blank'>payjoin transaction</a> by adding inputs from the receiver.<br/>" +
|
||||
$"The amount being sent may appear higher but is in fact almost same.<br/><br/>" +
|
||||
$"If you cancel refuse to sign this transaction, the payment will proceed without payjoin"
|
||||
});
|
||||
return ViewVault(walletId, proposedPayjoin, vm.PayJoinEndpointUrl, psbt);
|
||||
}
|
||||
}
|
||||
catch (PayjoinReceiverException ex)
|
||||
{
|
||||
error = $"The payjoin receiver could not complete the payjoin: {ex.Message}";
|
||||
}
|
||||
catch (PayjoinSenderException ex)
|
||||
{
|
||||
error = $"We rejected the receiver's payjoin proposal: {ex.Message}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = $"Unexpected payjoin error: {ex.Message}";
|
||||
}
|
||||
|
||||
//we possibly exposed the tx to the receiver, so we need to broadcast straight away
|
||||
psbt.Finalize();
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
{
|
||||
Severity = StatusMessageModel.StatusSeverity.Warning,
|
||||
AllowDismiss = false,
|
||||
Html = $"The payjoin transaction could not be created.<br/>" +
|
||||
$"The original transaction was broadcasted instead. ({psbt.ExtractTransaction().GetHash()})<br/><br/>" +
|
||||
$"{error}"
|
||||
});
|
||||
return await WalletPSBTReady(walletId, vm, "broadcast");
|
||||
case "broadcast" when !psbt.IsAllFinalized() && !psbt.TryFinalize(out var errors):
|
||||
vm.SetErrors(errors);
|
||||
return View(nameof(WalletPSBTReady),vm);
|
||||
}
|
||||
var transaction = psbt.ExtractTransaction();
|
||||
try
|
||||
case "broadcast":
|
||||
{
|
||||
var broadcastResult = await ExplorerClientProvider.GetExplorerClient(network).BroadcastAsync(transaction);
|
||||
if (!broadcastResult.Success)
|
||||
var transaction = psbt.ExtractTransaction();
|
||||
try
|
||||
{
|
||||
vm.GlobalError = $"RPC Error while broadcasting: {broadcastResult.RPCCode} {broadcastResult.RPCCodeMessage} {broadcastResult.RPCMessage}";
|
||||
var broadcastResult = await ExplorerClientProvider.GetExplorerClient(network).BroadcastAsync(transaction);
|
||||
if (!broadcastResult.Success)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(vm.OriginalPSBT))
|
||||
{
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
{
|
||||
Severity = StatusMessageModel.StatusSeverity.Warning,
|
||||
AllowDismiss = false,
|
||||
Html = $"The payjoin transaction could not be broadcasted.<br/>({broadcastResult.RPCCode} {broadcastResult.RPCCodeMessage} {broadcastResult.RPCMessage}).<br/>The transaction has been reverted back to its original format and has been broadcast."
|
||||
});
|
||||
vm.PSBT = vm.OriginalPSBT;
|
||||
vm.OriginalPSBT = null;
|
||||
return await WalletPSBTReady(walletId, vm, "broadcast");
|
||||
}
|
||||
|
||||
vm.GlobalError = $"RPC Error while broadcasting: {broadcastResult.RPCCode} {broadcastResult.RPCCodeMessage} {broadcastResult.RPCMessage}";
|
||||
return View(nameof(WalletPSBTReady),vm);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
vm.GlobalError = "Error while broadcasting: " + ex.Message;
|
||||
return View(nameof(WalletPSBTReady),vm);
|
||||
}
|
||||
|
||||
if (!TempData.HasStatusMessage())
|
||||
{
|
||||
TempData[WellKnownTempData.SuccessMessage] = $"Transaction broadcasted successfully ({transaction.GetHash()})";
|
||||
}
|
||||
return RedirectToWalletTransaction(walletId, transaction);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
vm.GlobalError = "Error while broadcasting: " + ex.Message;
|
||||
case "analyze-psbt":
|
||||
return RedirectToWalletPSBT(psbt);
|
||||
default:
|
||||
vm.GlobalError = "Unknown command";
|
||||
return View(nameof(WalletPSBTReady),vm);
|
||||
}
|
||||
return RedirectToWalletTransaction(walletId, transaction);
|
||||
}
|
||||
else if (command == "analyze-psbt")
|
||||
{
|
||||
return RedirectToWalletPSBT(psbt);
|
||||
}
|
||||
else
|
||||
{
|
||||
vm.GlobalError = "Unknown command";
|
||||
return View(nameof(WalletPSBTReady),vm);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -2,10 +2,12 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.HostedServices;
|
||||
using BTCPayServer.ModelBinders;
|
||||
@ -30,7 +32,7 @@ using Newtonsoft.Json;
|
||||
namespace BTCPayServer.Controllers
|
||||
{
|
||||
[Route("wallets")]
|
||||
[Authorize(Policy = Policies.CanModifyStoreSettings.Key, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
[AutoValidateAntiforgeryToken]
|
||||
public partial class WalletsController : Controller
|
||||
{
|
||||
@ -48,6 +50,8 @@ namespace BTCPayServer.Controllers
|
||||
private readonly WalletReceiveStateService _WalletReceiveStateService;
|
||||
private readonly EventAggregator _EventAggregator;
|
||||
private readonly SettingsRepository _settingsRepository;
|
||||
private readonly DelayedTransactionBroadcaster _broadcaster;
|
||||
private readonly PayjoinClient _payjoinClient;
|
||||
public RateFetcher RateFetcher { get; }
|
||||
|
||||
CurrencyNameTable _currencyTable;
|
||||
@ -65,7 +69,9 @@ namespace BTCPayServer.Controllers
|
||||
BTCPayWalletProvider walletProvider,
|
||||
WalletReceiveStateService walletReceiveStateService,
|
||||
EventAggregator eventAggregator,
|
||||
SettingsRepository settingsRepository)
|
||||
SettingsRepository settingsRepository,
|
||||
DelayedTransactionBroadcaster broadcaster,
|
||||
PayjoinClient payjoinClient)
|
||||
{
|
||||
_currencyTable = currencyTable;
|
||||
Repository = repo;
|
||||
@ -82,6 +88,8 @@ namespace BTCPayServer.Controllers
|
||||
_WalletReceiveStateService = walletReceiveStateService;
|
||||
_EventAggregator = eventAggregator;
|
||||
_settingsRepository = settingsRepository;
|
||||
_broadcaster = broadcaster;
|
||||
_payjoinClient = payjoinClient;
|
||||
}
|
||||
|
||||
// Borrowed from https://github.com/ManageIQ/guides/blob/master/labels.md
|
||||
@ -366,7 +374,7 @@ namespace BTCPayServer.Controllers
|
||||
|
||||
private async Task<bool> CanUseHotWallet()
|
||||
{
|
||||
var isAdmin = (await _authorizationService.AuthorizeAsync(User, Policies.CanModifyServerSettings.Key)).Succeeded;
|
||||
var isAdmin = (await _authorizationService.AuthorizeAsync(User, Policies.CanModifyServerSettings)).Succeeded;
|
||||
if (isAdmin)
|
||||
return true;
|
||||
var policies = await _settingsRepository.GetSettingAsync<PoliciesSettings>();
|
||||
@ -455,7 +463,9 @@ namespace BTCPayServer.Controllers
|
||||
if (network == null || network.ReadonlyWallet)
|
||||
return NotFound();
|
||||
vm.SupportRBF = network.SupportRBF;
|
||||
|
||||
vm.NBXSeedAvailable = await CanUseHotWallet() && !string.IsNullOrEmpty(await ExplorerClientProvider.GetExplorerClient(network)
|
||||
.GetMetadataAsync<string>(GetDerivationSchemeSettings(walletId).AccountDerivation,
|
||||
WellknownMetadataKeys.MasterHDKey, cancellation));
|
||||
if (!string.IsNullOrEmpty(bip21))
|
||||
{
|
||||
LoadFromBIP21(vm, bip21, network);
|
||||
@ -463,7 +473,36 @@ namespace BTCPayServer.Controllers
|
||||
}
|
||||
|
||||
decimal transactionAmountSum = 0;
|
||||
|
||||
if (command == "toggle-input-selection")
|
||||
{
|
||||
vm.InputSelection = !vm.InputSelection;
|
||||
}
|
||||
if (vm.InputSelection)
|
||||
{
|
||||
var schemeSettings = GetDerivationSchemeSettings(walletId);
|
||||
var walletBlobAsync = await WalletRepository.GetWalletInfo(walletId);
|
||||
var walletTransactionsInfoAsync = await WalletRepository.GetWalletTransactionsInfo(walletId);
|
||||
|
||||
var utxos = await _walletProvider.GetWallet(network).GetUnspentCoins(schemeSettings.AccountDerivation, cancellation);
|
||||
vm.InputsAvailable = utxos.Select(coin =>
|
||||
{
|
||||
walletTransactionsInfoAsync.TryGetValue(coin.OutPoint.Hash.ToString(), out var info);
|
||||
return new WalletSendModel.InputSelectionOption()
|
||||
{
|
||||
Outpoint = coin.OutPoint.ToString(),
|
||||
Amount = coin.Value.GetValue(network),
|
||||
Comment = info?.Comment,
|
||||
Labels = info == null? null :walletBlobAsync.GetLabels(info),
|
||||
Link = string.Format(CultureInfo.InvariantCulture, network.BlockExplorerLink, coin.OutPoint.Hash.ToString())
|
||||
};
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
if (command == "toggle-input-selection")
|
||||
{
|
||||
ModelState.Clear();
|
||||
return View(vm);
|
||||
}
|
||||
if (command == "add-output")
|
||||
{
|
||||
ModelState.Clear();
|
||||
@ -564,28 +603,28 @@ namespace BTCPayServer.Controllers
|
||||
return View(vm);
|
||||
}
|
||||
derivationScheme.RebaseKeyPaths(psbt.PSBT);
|
||||
|
||||
switch (command)
|
||||
{
|
||||
case "vault":
|
||||
return ViewVault(walletId, psbt.PSBT);
|
||||
return ViewVault(walletId, psbt.PSBT, vm.PayJoinEndpointUrl);
|
||||
case "nbx-seed":
|
||||
var extKey = await ExplorerClientProvider.GetExplorerClient(network)
|
||||
.GetMetadataAsync<string>(derivationScheme.AccountDerivation, WellknownMetadataKeys.MasterHDKey, cancellation);
|
||||
|
||||
return SignWithSeed(walletId, new SignWithSeedViewModel()
|
||||
{
|
||||
PayJoinEndpointUrl = vm.PayJoinEndpointUrl,
|
||||
SeedOrKey = extKey,
|
||||
PSBT = psbt.PSBT.ToBase64()
|
||||
});
|
||||
case "ledger":
|
||||
return ViewWalletSendLedger(walletId, psbt.PSBT, psbt.ChangeAddress);
|
||||
case "seed":
|
||||
return SignWithSeed(walletId, psbt.PSBT.ToBase64());
|
||||
return SignWithSeed(walletId, psbt.PSBT.ToBase64(), vm.PayJoinEndpointUrl);
|
||||
case "analyze-psbt":
|
||||
var name =
|
||||
$"Send-{string.Join('_', vm.Outputs.Select(output => $"{output.Amount}->{output.DestinationAddress}{(output.SubtractFeesFromOutput ? "-Fees" : string.Empty)}"))}.psbt";
|
||||
return RedirectToWalletPSBT(psbt.PSBT, name);
|
||||
return RedirectToWalletPSBT(psbt.PSBT, name, vm.PayJoinEndpointUrl);
|
||||
default:
|
||||
return View(vm);
|
||||
}
|
||||
@ -620,24 +659,41 @@ namespace BTCPayServer.Controllers
|
||||
$"Payment {(string.IsNullOrEmpty(uriBuilder.Label) ? string.Empty : $" to {uriBuilder.Label}")} {(string.IsNullOrEmpty(uriBuilder.Message) ? string.Empty : $" for {uriBuilder.Message}")}"
|
||||
});
|
||||
}
|
||||
uriBuilder.UnknowParameters.TryGetValue(PayjoinClient.BIP21EndpointKey, out var vmPayJoinEndpointUrl);
|
||||
vm.PayJoinEndpointUrl = vmPayJoinEndpointUrl;
|
||||
}
|
||||
catch (Exception)
|
||||
catch
|
||||
{
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
try
|
||||
{
|
||||
Severity = StatusMessageModel.StatusSeverity.Error,
|
||||
Message = "The provided BIP21 payment URI was malformed"
|
||||
});
|
||||
vm.Outputs = new List<WalletSendModel.TransactionOutput>()
|
||||
{
|
||||
new WalletSendModel.TransactionOutput()
|
||||
{
|
||||
DestinationAddress = BitcoinAddress.Create(bip21, network.NBitcoinNetwork).ToString()
|
||||
}
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
{
|
||||
Severity = StatusMessageModel.StatusSeverity.Error,
|
||||
Message = "The provided BIP21 payment URI was malformed"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ModelState.Clear();
|
||||
}
|
||||
|
||||
private IActionResult ViewVault(WalletId walletId, PSBT psbt)
|
||||
private IActionResult ViewVault(WalletId walletId, PSBT psbt, string payJoinEndpointUrl, PSBT originalPSBT = null)
|
||||
{
|
||||
return View("WalletSendVault", new WalletSendVaultModel()
|
||||
return View(nameof(WalletSendVault), new WalletSendVaultModel()
|
||||
{
|
||||
PayJoinEndpointUrl = payJoinEndpointUrl,
|
||||
WalletId = walletId.ToString(),
|
||||
OriginalPSBT = originalPSBT?.ToBase64(),
|
||||
PSBT = psbt.ToBase64(),
|
||||
WebsocketPath = this.Url.Action(nameof(VaultController.VaultBridgeConnection), "Vault", new { walletId = walletId.ToString() })
|
||||
});
|
||||
@ -645,12 +701,12 @@ namespace BTCPayServer.Controllers
|
||||
|
||||
[HttpPost]
|
||||
[Route("{walletId}/vault")]
|
||||
public IActionResult SubmitVault([ModelBinder(typeof(WalletIdModelBinder))]
|
||||
public IActionResult WalletSendVault([ModelBinder(typeof(WalletIdModelBinder))]
|
||||
WalletId walletId, WalletSendVaultModel model)
|
||||
{
|
||||
return RedirectToWalletPSBTReady(model.PSBT);
|
||||
return RedirectToWalletPSBTReady(model.PSBT, originalPsbt: model.OriginalPSBT, payJoinEndpointUrl: model.PayJoinEndpointUrl);
|
||||
}
|
||||
private IActionResult RedirectToWalletPSBTReady(string psbt, string signingKey= null, string signingKeyPath = null)
|
||||
private IActionResult RedirectToWalletPSBTReady(string psbt, string signingKey= null, string signingKeyPath = null, string originalPsbt = null, string payJoinEndpointUrl = null)
|
||||
{
|
||||
var vm = new PostRedirectViewModel()
|
||||
{
|
||||
@ -659,6 +715,8 @@ namespace BTCPayServer.Controllers
|
||||
Parameters =
|
||||
{
|
||||
new KeyValuePair<string, string>("psbt", psbt),
|
||||
new KeyValuePair<string, string>("originalPsbt", originalPsbt),
|
||||
new KeyValuePair<string, string>("payJoinEndpointUrl", payJoinEndpointUrl),
|
||||
new KeyValuePair<string, string>("SigningKey", signingKey),
|
||||
new KeyValuePair<string, string>("SigningKeyPath", signingKeyPath)
|
||||
}
|
||||
@ -666,7 +724,7 @@ namespace BTCPayServer.Controllers
|
||||
return View("PostRedirect", vm);
|
||||
}
|
||||
|
||||
private IActionResult RedirectToWalletPSBT(PSBT psbt, string fileName = null)
|
||||
private IActionResult RedirectToWalletPSBT(PSBT psbt, string fileName = null, string payJoinEndpointUrl = null)
|
||||
{
|
||||
var vm = new PostRedirectViewModel()
|
||||
{
|
||||
@ -679,6 +737,8 @@ namespace BTCPayServer.Controllers
|
||||
};
|
||||
if (!string.IsNullOrEmpty(fileName))
|
||||
vm.Parameters.Add(new KeyValuePair<string, string>("fileName", fileName));
|
||||
if (!string.IsNullOrEmpty(payJoinEndpointUrl))
|
||||
vm.Parameters.Add(new KeyValuePair<string, string>("payJoinEndpointUrl", payJoinEndpointUrl));
|
||||
return View("PostRedirect", vm);
|
||||
}
|
||||
|
||||
@ -725,10 +785,11 @@ namespace BTCPayServer.Controllers
|
||||
|
||||
[HttpGet("{walletId}/psbt/seed")]
|
||||
public IActionResult SignWithSeed([ModelBinder(typeof(WalletIdModelBinder))]
|
||||
WalletId walletId,string psbt)
|
||||
WalletId walletId,string psbt, string payJoinEndpointUrl)
|
||||
{
|
||||
return View(nameof(SignWithSeed), new SignWithSeedViewModel()
|
||||
{
|
||||
PayJoinEndpointUrl = payJoinEndpointUrl,
|
||||
PSBT = psbt
|
||||
});
|
||||
}
|
||||
@ -796,9 +857,10 @@ namespace BTCPayServer.Controllers
|
||||
return View(viewModel);
|
||||
}
|
||||
ModelState.Remove(nameof(viewModel.PSBT));
|
||||
return RedirectToWalletPSBTReady(psbt.ToBase64(), signingKey.GetWif(network.NBitcoinNetwork).ToString(), rootedKeyPath?.ToString());
|
||||
return RedirectToWalletPSBTReady(psbt.ToBase64(), signingKey.GetWif(network.NBitcoinNetwork).ToString(), rootedKeyPath?.ToString(), viewModel.OriginalPSBT, viewModel.PayJoinEndpointUrl);
|
||||
}
|
||||
|
||||
|
||||
|
||||
private bool PSBTChanged(PSBT psbt, Action act)
|
||||
{
|
||||
var before = psbt.ToBase64();
|
||||
@ -820,7 +882,6 @@ namespace BTCPayServer.Controllers
|
||||
var wallet = _walletProvider.GetWallet(network);
|
||||
var derivationSettings = GetDerivationSchemeSettings(walletId);
|
||||
wallet.InvalidateCache(derivationSettings.AccountDerivation);
|
||||
TempData[WellKnownTempData.SuccessMessage] = $"Transaction broadcasted successfully ({transaction.GetHash().ToString()})";
|
||||
}
|
||||
return RedirectToAction(nameof(WalletTransactions), new { walletId = walletId.ToString() });
|
||||
}
|
||||
@ -839,7 +900,7 @@ namespace BTCPayServer.Controllers
|
||||
|
||||
var vm = new RescanWalletModel();
|
||||
vm.IsFullySync = _dashboard.IsFullySynched(walletId.CryptoCode, out var unused);
|
||||
vm.IsServerAdmin = (await _authorizationService.AuthorizeAsync(User, Policies.CanModifyServerSettings.Key)).Succeeded;
|
||||
vm.IsServerAdmin = (await _authorizationService.AuthorizeAsync(User, Policies.CanModifyServerSettings)).Succeeded;
|
||||
vm.IsSupportedByCurrency = _dashboard.Get(walletId.CryptoCode)?.Status?.BitcoinStatus?.Capabilities?.CanScanTxoutSet == true;
|
||||
var explorer = ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode);
|
||||
var scanProgress = await explorer.GetScanUTXOSetInformationAsync(paymentMethod.AccountDerivation);
|
||||
@ -869,7 +930,7 @@ namespace BTCPayServer.Controllers
|
||||
|
||||
[HttpPost]
|
||||
[Route("{walletId}/rescan")]
|
||||
[Authorize(Policy = Policies.CanModifyServerSettings.Key, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
[Authorize(Policy = Policies.CanModifyServerSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
public async Task<IActionResult> WalletRescan(
|
||||
[ModelBinder(typeof(WalletIdModelBinder))]
|
||||
WalletId walletId, RescanWalletModel vm)
|
||||
|
26
BTCPayServer/Data/APIKeyDataExtensions.cs
Normal file
26
BTCPayServer/Data/APIKeyDataExtensions.cs
Normal file
@ -0,0 +1,26 @@
|
||||
using NBXplorer;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace BTCPayServer.Data
|
||||
{
|
||||
public static class APIKeyDataExtensions
|
||||
{
|
||||
public static APIKeyBlob GetBlob(this APIKeyData apiKeyData)
|
||||
{
|
||||
var result = apiKeyData.Blob == null
|
||||
? new APIKeyBlob()
|
||||
: JObject.Parse(ZipUtils.Unzip(apiKeyData.Blob)).ToObject<APIKeyBlob>();
|
||||
return result;
|
||||
}
|
||||
|
||||
public static bool SetBlob(this APIKeyData apiKeyData, APIKeyBlob blob)
|
||||
{
|
||||
var original = new Serializer(null).ToString(apiKeyData.GetBlob());
|
||||
var newBlob = new Serializer(null).ToString(blob);
|
||||
if (original == newBlob)
|
||||
return false;
|
||||
apiKeyData.Blob = ZipUtils.Zip(newBlob);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
@ -93,9 +93,8 @@ namespace BTCPayServer.Data
|
||||
public CurrencyValue LightningMaxValue { get; set; }
|
||||
public bool LightningAmountInSatoshi { get; set; }
|
||||
|
||||
public string CustomLogo { get; set; }
|
||||
|
||||
public string CustomCSS { get; set; }
|
||||
public string CustomLogo { get; set; }
|
||||
public string HtmlTitle { get; set; }
|
||||
|
||||
public bool RateScripting { get; set; }
|
||||
@ -173,6 +172,7 @@ namespace BTCPayServer.Data
|
||||
|
||||
public EmailSettings EmailSettings { get; set; }
|
||||
public bool RedirectAutomatically { get; set; }
|
||||
public bool PayJoinEnabled { get; set; }
|
||||
|
||||
public IPaymentFilter GetExcludedPaymentMethods()
|
||||
{
|
||||
|
@ -67,6 +67,8 @@ namespace BTCPayServer.Data
|
||||
|
||||
public static IEnumerable<ISupportedPaymentMethod> GetSupportedPaymentMethods(this StoreData storeData, BTCPayNetworkProvider networks)
|
||||
{
|
||||
if (storeData == null)
|
||||
throw new ArgumentNullException(nameof(storeData));
|
||||
networks = networks.UnfilteredNetworks;
|
||||
#pragma warning disable CS0618
|
||||
bool btcReturned = false;
|
||||
|
@ -6,5 +6,11 @@ namespace BTCPayServer.Events
|
||||
{
|
||||
public NewTransactionEvent NewTransactionEvent { get; set; }
|
||||
public string CryptoCode { get; set; }
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
var state = NewTransactionEvent.BlockId == null ? "Unconfirmed" : "Confirmed";
|
||||
return $"{CryptoCode}: New transaction {NewTransactionEvent.TransactionData.TransactionHash} ({state})";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
13
BTCPayServer/Events/UserRegisteredEvent.cs
Normal file
13
BTCPayServer/Events/UserRegisteredEvent.cs
Normal file
@ -0,0 +1,13 @@
|
||||
using System;
|
||||
using BTCPayServer.Data;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace BTCPayServer.Events
|
||||
{
|
||||
public class UserRegisteredEvent
|
||||
{
|
||||
public ApplicationUser User { get; set; }
|
||||
public bool Admin { get; set; }
|
||||
public Uri RequestUri { get; set; }
|
||||
}
|
||||
}
|
@ -1,7 +1,13 @@
|
||||
using System;
|
||||
|
||||
namespace BTCPayServer.Events
|
||||
{
|
||||
public class WalletChangedEvent
|
||||
{
|
||||
public WalletId WalletId { get; set; }
|
||||
public override string ToString()
|
||||
{
|
||||
return String.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -36,6 +36,7 @@ using System.Net;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.ViewFeatures;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using BTCPayServer.Payments.Bitcoin;
|
||||
|
||||
namespace BTCPayServer
|
||||
{
|
||||
@ -136,15 +137,37 @@ namespace BTCPayServer
|
||||
catch { }
|
||||
finally { try { webSocket.Dispose(); } catch { } }
|
||||
}
|
||||
public static async Task<Dictionary<uint256, TransactionResult>> GetTransactions(this BTCPayWallet client, uint256[] hashes, CancellationToken cts = default(CancellationToken))
|
||||
|
||||
public static IEnumerable<BitcoinLikePaymentData> GetAllBitcoinPaymentData(this InvoiceEntity invoice)
|
||||
{
|
||||
return invoice.GetPayments()
|
||||
.Where(p => p.GetPaymentMethodId().PaymentType == PaymentTypes.BTCLike)
|
||||
.Select(p => (BitcoinLikePaymentData)p.GetCryptoPaymentData());
|
||||
}
|
||||
|
||||
public static async Task<Dictionary<uint256, TransactionResult>> GetTransactions(this BTCPayWallet client, uint256[] hashes, bool includeOffchain = false, CancellationToken cts = default(CancellationToken))
|
||||
{
|
||||
hashes = hashes.Distinct().ToArray();
|
||||
var transactions = hashes
|
||||
.Select(async o => await client.GetTransactionAsync(o, cts))
|
||||
.Select(async o => await client.GetTransactionAsync(o, includeOffchain, cts))
|
||||
.ToArray();
|
||||
await Task.WhenAll(transactions).ConfigureAwait(false);
|
||||
return transactions.Select(t => t.Result).Where(t => t != null).ToDictionary(o => o.Transaction.GetHash());
|
||||
}
|
||||
|
||||
public static async Task<PSBT> UpdatePSBT(this ExplorerClientProvider explorerClientProvider, DerivationSchemeSettings derivationSchemeSettings, PSBT psbt)
|
||||
{
|
||||
var result = await explorerClientProvider.GetExplorerClient(psbt.Network.NetworkSet.CryptoCode).UpdatePSBTAsync(new UpdatePSBTRequest()
|
||||
{
|
||||
PSBT = psbt,
|
||||
DerivationScheme = derivationSchemeSettings.AccountDerivation
|
||||
});
|
||||
if (result == null)
|
||||
return null;
|
||||
derivationSchemeSettings.RebaseKeyPaths(result.PSBT);
|
||||
return result.PSBT;
|
||||
}
|
||||
|
||||
public static string WithTrailingSlash(this string str)
|
||||
{
|
||||
if (str.EndsWith("/", StringComparison.InvariantCulture))
|
||||
@ -425,6 +448,15 @@ namespace BTCPayServer
|
||||
ctx.Items["BTCPAY.STOREDATA"] = storeData;
|
||||
}
|
||||
|
||||
public static StoreData[] GetStoresData(this HttpContext ctx)
|
||||
{
|
||||
return ctx.Items.TryGet("BTCPAY.STORESDATA") as StoreData[];
|
||||
}
|
||||
public static void SetStoresData(this HttpContext ctx, StoreData[] storeData)
|
||||
{
|
||||
ctx.Items["BTCPAY.STORESDATA"] = storeData;
|
||||
}
|
||||
|
||||
private static JsonSerializerSettings jsonSettings = new JsonSerializerSettings { ContractResolver = new CamelCasePropertyNamesContractResolver() };
|
||||
public static string ToJson(this object o)
|
||||
{
|
||||
|
@ -1,20 +1,16 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using BTCPayServer.Controllers;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
|
||||
namespace Microsoft.AspNetCore.Mvc
|
||||
{
|
||||
public static class UrlHelperExtensions
|
||||
{
|
||||
public static string EmailConfirmationLink(this IUrlHelper urlHelper, string userId, string code, string scheme)
|
||||
public static string EmailConfirmationLink(this LinkGenerator urlHelper, string userId, string code, string scheme, HostString host, string pathbase)
|
||||
{
|
||||
return urlHelper.Action(
|
||||
action: nameof(AccountController.ConfirmEmail),
|
||||
controller: "Account",
|
||||
values: new { userId, code },
|
||||
protocol: scheme);
|
||||
return urlHelper.GetUriByAction( nameof(AccountController.ConfirmEmail), "Account",
|
||||
new {userId, code}, scheme, host, pathbase);
|
||||
}
|
||||
|
||||
public static string ResetPasswordCallbackLink(this IUrlHelper urlHelper, string userId, string code, string scheme)
|
||||
|
@ -22,7 +22,7 @@ namespace BTCPayServer.HostedServices
|
||||
public void Update(ThemeSettings data)
|
||||
{
|
||||
if (String.IsNullOrWhiteSpace(data.ThemeCssUri))
|
||||
_themeUri = "/main/themes/classic.css";
|
||||
_themeUri = "/main/themes/default.css";
|
||||
else
|
||||
_themeUri = data.ThemeCssUri;
|
||||
|
||||
|
@ -0,0 +1,40 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Logging;
|
||||
using BTCPayServer.Services;
|
||||
|
||||
namespace BTCPayServer.HostedServices
|
||||
{
|
||||
public class DelayedTransactionBroadcasterHostedService : BaseAsyncService
|
||||
{
|
||||
private readonly DelayedTransactionBroadcaster _transactionBroadcaster;
|
||||
|
||||
public DelayedTransactionBroadcasterHostedService(DelayedTransactionBroadcaster transactionBroadcaster)
|
||||
{
|
||||
_transactionBroadcaster = transactionBroadcaster;
|
||||
}
|
||||
|
||||
internal override Task[] InitializeTasks()
|
||||
{
|
||||
return new Task[]
|
||||
{
|
||||
CreateLoopTask(Rebroadcast)
|
||||
};
|
||||
}
|
||||
|
||||
public TimeSpan PollInternal { get; set; } = TimeSpan.FromMinutes(1.0);
|
||||
|
||||
async Task Rebroadcast()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
await _transactionBroadcaster.ProcessAll(Cancellation);
|
||||
await Task.Delay(PollInternal, Cancellation);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -38,14 +38,13 @@ namespace BTCPayServer.HostedServices
|
||||
|
||||
public bool IsFullySynched()
|
||||
{
|
||||
return _Summaries.All(s => s.Value.Status != null && s.Value.Status.IsFullySynched);
|
||||
return _Summaries.All(s => s.Value.Status?.IsFullySynched is true);
|
||||
}
|
||||
|
||||
public bool IsFullySynched(string cryptoCode, out NBXplorerSummary summary)
|
||||
{
|
||||
return _Summaries.TryGetValue(cryptoCode.ToUpperInvariant(), out summary) &&
|
||||
summary.Status != null &&
|
||||
summary.Status.IsFullySynched;
|
||||
summary.Status?.IsFullySynched is true;
|
||||
}
|
||||
public NBXplorerSummary Get(string cryptoCode)
|
||||
{
|
||||
@ -88,6 +87,7 @@ namespace BTCPayServer.HostedServices
|
||||
_Client = client;
|
||||
_Aggregator = aggregator;
|
||||
_Dashboard = dashboard;
|
||||
_Dashboard.Publish(_Network, State, null, null);
|
||||
}
|
||||
|
||||
NBXplorerDashboard _Dashboard;
|
||||
|
366
BTCPayServer/HostedServices/Socks5HttpProxyServer.cs
Normal file
366
BTCPayServer/HostedServices/Socks5HttpProxyServer.cs
Normal file
@ -0,0 +1,366 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.IO.Pipelines;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Configuration;
|
||||
using BTCPayServer.Lightning.Eclair.Models;
|
||||
using BTCPayServer.Logging;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NBitcoin;
|
||||
using NBitcoin.Socks;
|
||||
|
||||
namespace BTCPayServer.HostedServices
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// This is a very simple Socks HTTP proxy, that can be used through HttpClient.WebProxy
|
||||
/// However, it only supports a single request/response, so the client must specify Connection: close to not
|
||||
/// reuse the TCP connection to the proxy for another requests.
|
||||
/// Inspired from https://devblogs.microsoft.com/dotnet/system-io-pipelines-high-performance-io-in-net/
|
||||
/// </summary>
|
||||
public class Socks5HttpProxyServer : IHostedService
|
||||
{
|
||||
class ProxyConnection
|
||||
{
|
||||
public ServerContext ServerContext;
|
||||
public Socket ClientSocket;
|
||||
public Socket SocksSocket;
|
||||
public CancellationToken CancellationToken;
|
||||
public CancellationTokenSource CancellationTokenSource;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Socks5HttpProxyServer.Dispose(ClientSocket);
|
||||
Socks5HttpProxyServer.Dispose(SocksSocket);
|
||||
CancellationTokenSource.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class ServerContext
|
||||
{
|
||||
public EndPoint SocksEndpoint;
|
||||
public Socket ServerSocket;
|
||||
public CancellationToken CancellationToken;
|
||||
public int ConnectionCount;
|
||||
}
|
||||
private readonly BTCPayServerOptions _opts;
|
||||
|
||||
public Socks5HttpProxyServer(Configuration.BTCPayServerOptions opts)
|
||||
{
|
||||
_opts = opts;
|
||||
}
|
||||
private ServerContext _ServerContext;
|
||||
private CancellationTokenSource _Cts;
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_opts.SocksEndpoint is null || _ServerContext != null)
|
||||
return Task.CompletedTask;
|
||||
_Cts = new CancellationTokenSource();
|
||||
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
socket.Bind(new IPEndPoint(IPAddress.Loopback, 0));
|
||||
Port = ((IPEndPoint)(socket.LocalEndPoint)).Port;
|
||||
Uri = new Uri($"http://127.0.0.1:{Port}");
|
||||
socket.Listen(5);
|
||||
_ServerContext = new ServerContext()
|
||||
{
|
||||
SocksEndpoint = _opts.SocksEndpoint,
|
||||
ServerSocket = socket,
|
||||
CancellationToken = _Cts.Token,
|
||||
ConnectionCount = 0
|
||||
};
|
||||
socket.BeginAccept(Accept, _ServerContext);
|
||||
Logs.PayServer.LogInformation($"Internal Socks HTTP Proxy listening at {Uri}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public int Port { get; private set; }
|
||||
public Uri Uri { get; private set; }
|
||||
|
||||
static void Accept(IAsyncResult ar)
|
||||
{
|
||||
var ctx = (ServerContext)ar.AsyncState;
|
||||
Socket clientSocket = null;
|
||||
try
|
||||
{
|
||||
clientSocket = ctx.ServerSocket.EndAccept(ar);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (ctx.CancellationToken.IsCancellationRequested)
|
||||
{
|
||||
Dispose(clientSocket);
|
||||
return;
|
||||
}
|
||||
var toSocksProxy = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
var connectionCts = CancellationTokenSource.CreateLinkedTokenSource(ctx.CancellationToken);
|
||||
toSocksProxy.BeginConnect(ctx.SocksEndpoint, ConnectToSocks, new ProxyConnection()
|
||||
{
|
||||
ServerContext = ctx,
|
||||
ClientSocket = clientSocket,
|
||||
SocksSocket = toSocksProxy,
|
||||
CancellationToken = connectionCts.Token,
|
||||
CancellationTokenSource = connectionCts
|
||||
});
|
||||
try
|
||||
{
|
||||
ctx.ServerSocket.BeginAccept(Accept, ctx);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
static void ConnectToSocks(IAsyncResult ar)
|
||||
{
|
||||
var connection = (ProxyConnection)ar.AsyncState;
|
||||
try
|
||||
{
|
||||
connection.SocksSocket.EndConnect(ar);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
connection.Dispose();
|
||||
return;
|
||||
}
|
||||
Interlocked.Increment(ref connection.ServerContext.ConnectionCount);
|
||||
var pipe = new Pipe(PipeOptions.Default);
|
||||
var reading = FillPipeAsync(connection.ClientSocket, pipe.Writer, connection.CancellationToken)
|
||||
.ContinueWith(_ => connection.CancellationTokenSource.Cancel(), TaskScheduler.Default);
|
||||
var writing = ReadPipeAsync(connection.SocksSocket, connection.ClientSocket, pipe.Reader, connection.CancellationToken)
|
||||
.ContinueWith(_ => connection.CancellationTokenSource.Cancel(), TaskScheduler.Default);
|
||||
_ = Task.WhenAll(reading, writing)
|
||||
.ContinueWith(_ =>
|
||||
{
|
||||
connection.Dispose();
|
||||
Interlocked.Decrement(ref connection.ServerContext.ConnectionCount);
|
||||
}, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
public int ConnectionCount => _ServerContext is ServerContext s ? s.ConnectionCount : 0;
|
||||
private static async Task ReadPipeAsync(Socket socksSocket, Socket clientSocket, PipeReader reader, CancellationToken cancellationToken)
|
||||
{
|
||||
bool handshaked = false;
|
||||
bool isConnect = false;
|
||||
string firstHeader = null;
|
||||
string httpVersion = null;
|
||||
while (true)
|
||||
{
|
||||
ReadResult result = await reader.ReadAsync(cancellationToken);
|
||||
ReadOnlySequence<byte> buffer = result.Buffer;
|
||||
SequencePosition? position = null;
|
||||
|
||||
if (!handshaked)
|
||||
{
|
||||
nextchunk:
|
||||
// Look for a EOL in the buffer
|
||||
position = buffer.PositionOf((byte)'\n');
|
||||
if (position == null)
|
||||
goto readnext;
|
||||
// Process the line
|
||||
var line = GetHeaderLine(buffer.Slice(0, position.Value));
|
||||
// Skip the line + the \n character (basically position)
|
||||
buffer = buffer.Slice(buffer.GetPosition(1, position.Value));
|
||||
if (firstHeader is null)
|
||||
{
|
||||
firstHeader = line;
|
||||
isConnect = line.StartsWith("CONNECT ", StringComparison.OrdinalIgnoreCase);
|
||||
if (isConnect)
|
||||
goto nextchunk;
|
||||
else
|
||||
goto handshake;
|
||||
}
|
||||
else if (line.Length == 1 && line[0] == '\r')
|
||||
goto handshake;
|
||||
else
|
||||
goto nextchunk;
|
||||
|
||||
handshake:
|
||||
var split = firstHeader.Split(' ');
|
||||
if (split.Length != 3)
|
||||
break;
|
||||
var targetConnection = split[1].Trim();
|
||||
EndPoint destinationEnpoint = null;
|
||||
if (isConnect)
|
||||
{
|
||||
if (!Utils.TryParseEndpoint(targetConnection,
|
||||
80,
|
||||
out destinationEnpoint))
|
||||
break;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!System.Uri.TryCreate(targetConnection, UriKind.Absolute, out var uri) ||
|
||||
(uri.Scheme != "http" && uri.Scheme != "https"))
|
||||
break;
|
||||
if (!Utils.TryParseEndpoint($"{uri.DnsSafeHost}:{uri.Port}",
|
||||
uri.Scheme == "http" ? 80 : 443,
|
||||
out destinationEnpoint))
|
||||
break;
|
||||
firstHeader = $"{split[0]} {uri.PathAndQuery} {split[2].TrimEnd()}";
|
||||
}
|
||||
|
||||
httpVersion = split[2].Trim();
|
||||
try
|
||||
{
|
||||
await NBitcoin.Socks.SocksHelper.Handshake(socksSocket, destinationEnpoint, cancellationToken);
|
||||
handshaked = true;
|
||||
if (isConnect)
|
||||
{
|
||||
await SendAsync(clientSocket,
|
||||
$"{httpVersion} 200 Connection established\r\nConnection: close\r\n\r\n",
|
||||
cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
await SendAsync(socksSocket, $"{firstHeader}\r\n", cancellationToken);
|
||||
foreach (ReadOnlyMemory<byte> segment in buffer)
|
||||
{
|
||||
await socksSocket.SendAsync(segment, SocketFlags.None, cancellationToken);
|
||||
}
|
||||
buffer = buffer.Slice(buffer.End);
|
||||
}
|
||||
_ = Relay(socksSocket, clientSocket, cancellationToken);
|
||||
}
|
||||
catch (SocksException e) when (e.SocksErrorCode == SocksErrorCode.HostUnreachable || e.SocksErrorCode == SocksErrorCode.HostUnreachable)
|
||||
{
|
||||
await SendAsync(clientSocket , $"{httpVersion} 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n", cancellationToken);
|
||||
goto done;
|
||||
}
|
||||
catch (SocksException e)
|
||||
{
|
||||
await SendAsync(clientSocket , $"{httpVersion} 500 Internal Server Error\r\nContent-Length: 0\r\nX-Proxy-Error-Type: Socks {e.SocksErrorCode}\r\n\r\n", cancellationToken);
|
||||
goto done;
|
||||
}
|
||||
catch (SocketException e)
|
||||
{
|
||||
await SendAsync(clientSocket , $"{httpVersion} 500 Internal Server Error\r\nContent-Length: 0\r\nX-Proxy-Error-Type: Socket {e.SocketErrorCode}\r\n\r\n", cancellationToken);
|
||||
goto done;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await SendAsync(clientSocket , $"{httpVersion} 500 Internal Server Error\r\n\r\n", cancellationToken);
|
||||
goto done;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (ReadOnlyMemory<byte> segment in buffer)
|
||||
{
|
||||
await socksSocket.SendAsync(segment, SocketFlags.None, cancellationToken);
|
||||
}
|
||||
buffer = buffer.Slice(buffer.End);
|
||||
}
|
||||
|
||||
readnext:
|
||||
// Tell the PipeReader how much of the buffer we have consumed
|
||||
reader.AdvanceTo(buffer.Start, buffer.End);
|
||||
// Stop reading if there's no more data coming
|
||||
if (result.IsCompleted)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
done:
|
||||
// Mark the PipeReader as complete
|
||||
reader.Complete();
|
||||
}
|
||||
|
||||
private const int BufferSize = 1024 * 5;
|
||||
private static async Task Relay(Socket from, Socket to, CancellationToken cancellationToken)
|
||||
{
|
||||
using var buffer = MemoryPool<byte>.Shared.Rent(BufferSize);
|
||||
while (true)
|
||||
{
|
||||
int bytesRead = await from.ReceiveAsync(buffer.Memory, SocketFlags.None, cancellationToken);
|
||||
if (bytesRead == 0)
|
||||
break;
|
||||
await to.SendAsync(buffer.Memory.Slice(0, bytesRead), SocketFlags.None, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task SendAsync(Socket clientSocket, string data, CancellationToken cancellationToken)
|
||||
{
|
||||
var bytes = new byte[Encoding.ASCII.GetByteCount(data)];
|
||||
Encoding.ASCII.GetBytes(data, bytes);
|
||||
await clientSocket.SendAsync(bytes, SocketFlags.None, cancellationToken);
|
||||
}
|
||||
|
||||
private static string GetHeaderLine(ReadOnlySequence<byte> buffer)
|
||||
{
|
||||
if (buffer.IsSingleSegment)
|
||||
{
|
||||
return Encoding.ASCII.GetString(buffer.First.Span);
|
||||
}
|
||||
|
||||
return string.Create((int)buffer.Length, buffer, (span, sequence) =>
|
||||
{
|
||||
foreach (var segment in sequence)
|
||||
{
|
||||
Encoding.ASCII.GetChars(segment.Span, span);
|
||||
|
||||
span = span.Slice(segment.Length);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task FillPipeAsync(Socket socket, PipeWriter writer, CancellationToken cancellationToken)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
Memory<byte> memory = writer.GetMemory(BufferSize);
|
||||
int bytesRead = await socket.ReceiveAsync(memory, SocketFlags.None, cancellationToken);
|
||||
if (bytesRead == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
writer.Advance(bytesRead);
|
||||
FlushResult result = await writer.FlushAsync(cancellationToken);
|
||||
if (result.IsCompleted)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
writer.Complete();
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_ServerContext is ServerContext ctx)
|
||||
{
|
||||
_Cts.Cancel();
|
||||
Dispose(ctx.ServerSocket);
|
||||
Logs.PayServer.LogInformation($"Internal Socks HTTP Proxy closed");
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
static void Dispose(Socket s)
|
||||
{
|
||||
try
|
||||
{
|
||||
s.Shutdown(SocketShutdown.Both);
|
||||
s.Close();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
|
||||
}
|
||||
finally
|
||||
{
|
||||
s.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
55
BTCPayServer/HostedServices/UserEventHostedService.cs
Normal file
55
BTCPayServer/HostedServices/UserEventHostedService.cs
Normal file
@ -0,0 +1,55 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Events;
|
||||
using BTCPayServer.Logging;
|
||||
using BTCPayServer.Services;
|
||||
using BTCPayServer.Services.Mails;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace BTCPayServer.HostedServices
|
||||
{
|
||||
public class UserEventHostedService : EventHostedServiceBase
|
||||
{
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly EmailSenderFactory _emailSenderFactory;
|
||||
private readonly LinkGenerator _generator;
|
||||
|
||||
public UserEventHostedService(EventAggregator eventAggregator, UserManager<ApplicationUser> userManager,
|
||||
EmailSenderFactory emailSenderFactory, LinkGenerator generator) : base(eventAggregator)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_emailSenderFactory = emailSenderFactory;
|
||||
_generator = generator;
|
||||
}
|
||||
|
||||
protected override void SubscibeToEvents()
|
||||
{
|
||||
Subscribe<UserRegisteredEvent>();
|
||||
}
|
||||
|
||||
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
|
||||
{
|
||||
switch (evt)
|
||||
{
|
||||
case UserRegisteredEvent userRegisteredEvent:
|
||||
Logs.PayServer.LogInformation($"A new user just registered {userRegisteredEvent.User.Email} {(userRegisteredEvent.Admin ? "(admin)" : "")}");
|
||||
if (!userRegisteredEvent.User.EmailConfirmed && userRegisteredEvent.User.RequiresEmailConfirmation)
|
||||
{
|
||||
|
||||
var code = await _userManager.GenerateEmailConfirmationTokenAsync(userRegisteredEvent.User);
|
||||
var callbackUrl = _generator.EmailConfirmationLink(userRegisteredEvent.User.Id, code, userRegisteredEvent.RequestUri.Scheme, new HostString(userRegisteredEvent.RequestUri.Host, userRegisteredEvent.RequestUri.Port), userRegisteredEvent.RequestUri.PathAndQuery);
|
||||
|
||||
_emailSenderFactory.GetEmailSender()
|
||||
.SendEmailConfirmation(userRegisteredEvent.User.Email, callbackUrl);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -26,14 +26,13 @@ using System.Threading;
|
||||
using BTCPayServer.Services.Wallets;
|
||||
using BTCPayServer.Logging;
|
||||
using BTCPayServer.HostedServices;
|
||||
using BTCPayServer.Hosting.OpenApi;
|
||||
using BTCPayServer.PaymentRequest;
|
||||
using BTCPayServer.Payments;
|
||||
using BTCPayServer.Payments.Bitcoin;
|
||||
using BTCPayServer.Payments.Changelly;
|
||||
using BTCPayServer.Payments.Lightning;
|
||||
using BTCPayServer.Payments.PayJoin;
|
||||
using BTCPayServer.Security;
|
||||
using BTCPayServer.Security.APIKeys;
|
||||
using BTCPayServer.Services.PaymentRequests;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
using NBXplorer.DerivationStrategy;
|
||||
@ -45,6 +44,7 @@ using BundlerMinifier.TagHelpers;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using BTCPayServer.Security.Bitpay;
|
||||
using Serilog;
|
||||
using BTCPayServer.Security.GreenField;
|
||||
|
||||
namespace BTCPayServer.Hosting
|
||||
{
|
||||
@ -63,6 +63,7 @@ namespace BTCPayServer.Hosting
|
||||
{
|
||||
httpClient.Timeout = Timeout.InfiniteTimeSpan;
|
||||
});
|
||||
services.AddPayJoinServices();
|
||||
services.AddMoneroLike();
|
||||
services.TryAddSingleton<SettingsRepository>();
|
||||
services.TryAddSingleton<TorServices>();
|
||||
@ -204,6 +205,7 @@ namespace BTCPayServer.Hosting
|
||||
services.AddSingleton<IHostedService, BackgroundJobSchedulerHostedService>();
|
||||
services.AddSingleton<IHostedService, AppHubStreamer>();
|
||||
services.AddSingleton<IHostedService, AppInventoryUpdaterHostedService>();
|
||||
services.AddSingleton<IHostedService, UserEventHostedService>();
|
||||
services.AddSingleton<IHostedService, DynamicDnsHostedService>();
|
||||
services.AddSingleton<IHostedService, TorServicesHostedService>();
|
||||
services.AddSingleton<IHostedService, PaymentRequestStreamer>();
|
||||
@ -256,15 +258,17 @@ namespace BTCPayServer.Hosting
|
||||
if (btcPayEnv.IsDevelopping)
|
||||
{
|
||||
rateLimits.SetZone($"zone={ZoneLimits.Login} rate=1000r/min burst=100 nodelay");
|
||||
rateLimits.SetZone($"zone={ZoneLimits.Register} rate=1000r/min burst=100 nodelay");
|
||||
rateLimits.SetZone($"zone={ZoneLimits.PayJoin} rate=1000r/min burst=100 nodelay");
|
||||
}
|
||||
else
|
||||
{
|
||||
rateLimits.SetZone($"zone={ZoneLimits.Login} rate=5r/min burst=3 nodelay");
|
||||
rateLimits.SetZone($"zone={ZoneLimits.Register} rate=2r/min burst=2 nodelay");
|
||||
rateLimits.SetZone($"zone={ZoneLimits.PayJoin} rate=5r/min burst=3 nodelay");
|
||||
}
|
||||
return rateLimits;
|
||||
});
|
||||
services.AddBTCPayOpenApi();
|
||||
|
||||
services.AddLogging(logBuilder =>
|
||||
{
|
||||
var debugLogFile = BTCPayServerOptions.GetDebugLog(configuration);
|
||||
@ -292,7 +296,6 @@ namespace BTCPayServer.Hosting
|
||||
public static IApplicationBuilder UsePayServer(this IApplicationBuilder app)
|
||||
{
|
||||
app.UseMiddleware<BTCPayMiddleware>();
|
||||
app.UseBTCPayOpenApi();
|
||||
return app;
|
||||
}
|
||||
public static IApplicationBuilder UseHeadersOverride(this IApplicationBuilder app)
|
||||
|
@ -1,9 +0,0 @@
|
||||
using System;
|
||||
|
||||
|
||||
namespace BTCPayServer.Hosting.OpenApi
|
||||
{
|
||||
public class IncludeInOpenApiDocs : Attribute
|
||||
{
|
||||
}
|
||||
}
|
@ -1,96 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using BTCPayServer.Payments;
|
||||
using BTCPayServer.Security;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NJsonSchema;
|
||||
using NJsonSchema.Generation.TypeMappers;
|
||||
using NSwag;
|
||||
using NSwag.Generation.Processors.Security;
|
||||
|
||||
namespace BTCPayServer.Hosting.OpenApi
|
||||
{
|
||||
public static class OpenApiExtensions
|
||||
{
|
||||
public static IServiceCollection AddBTCPayOpenApi(this IServiceCollection serviceCollection)
|
||||
{
|
||||
|
||||
return serviceCollection.AddOpenApiDocument(config =>
|
||||
{
|
||||
config.PostProcess = document =>
|
||||
{
|
||||
document.Info.Version = "v1";
|
||||
document.Info.Title = "BTCPay Greenfield API";
|
||||
document.Info.Description = "A full API to use your BTCPay Server";
|
||||
document.Info.TermsOfService = null;
|
||||
document.Info.Contact = new NSwag.OpenApiContact
|
||||
{
|
||||
Name = "BTCPay Server", Email = string.Empty, Url = "https://btcpayserver.org"
|
||||
};
|
||||
};
|
||||
config.AddOperationFilter(context =>
|
||||
{
|
||||
var methodInfo = context.MethodInfo;
|
||||
if (methodInfo != null)
|
||||
{
|
||||
return methodInfo.CustomAttributes.Any(data =>
|
||||
data.AttributeType == typeof(IncludeInOpenApiDocs)) ||
|
||||
methodInfo.DeclaringType.CustomAttributes.Any(data =>
|
||||
data.AttributeType == typeof(IncludeInOpenApiDocs));
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
config.AddSecurity("APIKey", Enumerable.Empty<string>(),
|
||||
new OpenApiSecurityScheme
|
||||
{
|
||||
Type = OpenApiSecuritySchemeType.ApiKey,
|
||||
Name = "Authorization",
|
||||
In = OpenApiSecurityApiKeyLocation.Header,
|
||||
Description =
|
||||
"BTCPay Server supports authenticating and authorizing users through an API Key that is generated by them. Send the API Key as a header value to Authorization with the format: token {token}. For a smoother experience, you can generate a url that redirects users to an API key creation screen."
|
||||
});
|
||||
|
||||
config.OperationProcessors.Add(
|
||||
new BTCPayPolicyOperationProcessor("APIKey", AuthenticationSchemes.ApiKey));
|
||||
|
||||
config.TypeMappers.Add(
|
||||
new PrimitiveTypeMapper(typeof(PaymentType), s => s.Type = JsonObjectType.String));
|
||||
config.TypeMappers.Add(new PrimitiveTypeMapper(typeof(PaymentMethodId),
|
||||
s => s.Type = JsonObjectType.String));
|
||||
});
|
||||
}
|
||||
|
||||
public static IApplicationBuilder UseBTCPayOpenApi(this IApplicationBuilder builder)
|
||||
{
|
||||
return builder.UseOpenApi()
|
||||
.UseReDoc(settings => settings.Path = "/docs");
|
||||
}
|
||||
|
||||
|
||||
class BTCPayPolicyOperationProcessor : AspNetCoreOperationSecurityScopeProcessor
|
||||
{
|
||||
private readonly string _authScheme;
|
||||
|
||||
public BTCPayPolicyOperationProcessor(string x, string authScheme) : base(x)
|
||||
{
|
||||
_authScheme = authScheme;
|
||||
}
|
||||
|
||||
protected override IEnumerable<string> GetScopes(IEnumerable<AuthorizeAttribute> authorizeAttributes)
|
||||
{
|
||||
var result = authorizeAttributes
|
||||
.Where(attribute => attribute?.AuthenticationSchemes != null && attribute.Policy != null &&
|
||||
attribute.AuthenticationSchemes.Equals(_authScheme,
|
||||
StringComparison.InvariantCultureIgnoreCase))
|
||||
.Select(attribute => attribute.Policy);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -71,8 +71,18 @@ namespace BTCPayServer.Hosting
|
||||
// ScriptSrc = "'self' 'unsafe-inline'"
|
||||
//});
|
||||
})
|
||||
.ConfigureApiBehaviorOptions(options =>
|
||||
{
|
||||
var builtInFactory = options.InvalidModelStateResponseFactory;
|
||||
|
||||
options.InvalidModelStateResponseFactory = context =>
|
||||
{
|
||||
context.HttpContext.Response.StatusCode = (int)HttpStatusCode.UnprocessableEntity;
|
||||
return builtInFactory(context);
|
||||
};
|
||||
})
|
||||
.AddNewtonsoftJson()
|
||||
#if DEBUG
|
||||
#if RAZOR_RUNTIME_COMPILE
|
||||
.AddRazorRuntimeCompilation()
|
||||
#endif
|
||||
.AddControllersAsServices();
|
||||
|
64
BTCPayServer/JsonConverters/DateTimeMilliJsonConverter.cs
Normal file
64
BTCPayServer/JsonConverters/DateTimeMilliJsonConverter.cs
Normal file
@ -0,0 +1,64 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Reflection;
|
||||
using Newtonsoft.Json;
|
||||
using NBitcoin.JsonConverters;
|
||||
|
||||
namespace BTCPayServer.JsonConverters
|
||||
{
|
||||
class DateTimeMilliJsonConverter : JsonConverter
|
||||
{
|
||||
public override bool CanConvert(Type objectType)
|
||||
{
|
||||
return typeof(DateTime).GetTypeInfo().IsAssignableFrom(objectType.GetTypeInfo()) ||
|
||||
typeof(DateTimeOffset).GetTypeInfo().IsAssignableFrom(objectType.GetTypeInfo()) ||
|
||||
typeof(DateTimeOffset?).GetTypeInfo().IsAssignableFrom(objectType.GetTypeInfo());
|
||||
}
|
||||
|
||||
static DateTimeOffset unixRef = new DateTimeOffset(1970, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
|
||||
{
|
||||
if (reader.Value == null)
|
||||
return null;
|
||||
if (reader.TokenType == JsonToken.Null)
|
||||
return null;
|
||||
var result = UnixTimeToDateTime((ulong)(long)reader.Value);
|
||||
if (objectType == typeof(DateTime))
|
||||
return result.UtcDateTime;
|
||||
return result;
|
||||
}
|
||||
|
||||
private DateTimeOffset UnixTimeToDateTime(ulong value)
|
||||
{
|
||||
var v = (long)value;
|
||||
if(v < 0)
|
||||
throw new FormatException("Invalid datetime (less than 1/1/1970)");
|
||||
return unixRef + TimeSpan.FromMilliseconds((long)v);
|
||||
}
|
||||
private long DateTimeToUnixTime(in DateTime time)
|
||||
{
|
||||
var date = ((DateTimeOffset)time).ToUniversalTime();
|
||||
long v = (long)(date - unixRef).TotalMilliseconds;
|
||||
if(v < 0)
|
||||
throw new FormatException("Invalid datetime (less than 1/1/1970)");
|
||||
return v;
|
||||
}
|
||||
|
||||
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
|
||||
{
|
||||
DateTime time;
|
||||
if (value is DateTime)
|
||||
time = (DateTime)value;
|
||||
else
|
||||
time = ((DateTimeOffset)value).UtcDateTime;
|
||||
|
||||
if (time < UnixTimeToDateTime(0))
|
||||
time = UnixTimeToDateTime(0).UtcDateTime;
|
||||
writer.WriteValue(DateTimeToUnixTime(time));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
|
@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
@ -23,6 +23,7 @@ namespace BTCPayServer.Models.InvoicingModels
|
||||
|
||||
public bool Replaced { get; set; }
|
||||
public BitcoinLikePaymentData CryptoPaymentData { get; set; }
|
||||
public string AdditionalInformation { get; set; }
|
||||
}
|
||||
|
||||
public class OffChainPaymentViewModel
|
||||
|
@ -24,9 +24,9 @@ namespace BTCPayServer.Models.InvoicingModels
|
||||
public bool IsLightning { get; set; }
|
||||
public string CryptoCode { get; set; }
|
||||
}
|
||||
public string HtmlTitle { get; set; }
|
||||
public string CustomCSSLink { get; set; }
|
||||
public string CustomLogoLink { get; set; }
|
||||
public string HtmlTitle { get; set; }
|
||||
public string DefaultLang { get; set; }
|
||||
public bool LightningAmountInSatoshi { get; set; }
|
||||
public List<AvailableCrypto> AvailableCryptos { get; set; } = new List<AvailableCrypto>();
|
||||
|
@ -50,7 +50,7 @@ namespace BTCPayServer.Models.PaymentRequestViewModels
|
||||
[Required] public string StoreId { get; set; }
|
||||
|
||||
[Required]
|
||||
[Range(double.Epsilon, double.PositiveInfinity, ErrorMessage = "Please enter a value bigger than zero")]
|
||||
[Range(double.Epsilon, double.PositiveInfinity, ErrorMessage = "Please provide an amount greater than 0")]
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
[Display(Name = "The currency used for payment request. (e.g. BTC, LTC, USD, etc.)")]
|
||||
|
@ -19,6 +19,15 @@ namespace BTCPayServer.Models.StoreViewModels
|
||||
public PaymentMethodId PaymentId { get; set; }
|
||||
}
|
||||
public SelectList CryptoCurrencies { get; set; }
|
||||
|
||||
public void SetLanguages(LanguageService langService, string defaultLang)
|
||||
{
|
||||
defaultLang = langService.GetLanguages().Any(language => language.Code == defaultLang) ? defaultLang : "en";
|
||||
var choices = langService.GetLanguages().Select(o => new Format() { Name = o.DisplayName, Value = o.Code }).ToArray();
|
||||
var chosen = choices.FirstOrDefault(f => f.Value == defaultLang) ?? choices.FirstOrDefault();
|
||||
Languages = new SelectList(choices, nameof(chosen.Value), nameof(chosen.Name), chosen);
|
||||
DefaultLang = chosen.Value;
|
||||
}
|
||||
public SelectList Languages { get; set; }
|
||||
|
||||
[Display(Name = "Default payment method on checkout")]
|
||||
@ -57,14 +66,5 @@ namespace BTCPayServer.Models.StoreViewModels
|
||||
|
||||
[Display(Name = "Redirect invoice to redirect url automatically after paid")]
|
||||
public bool RedirectAutomatically { get; set; }
|
||||
|
||||
public void SetLanguages(LanguageService langService, string defaultLang)
|
||||
{
|
||||
defaultLang = langService.GetLanguages().Any(language => language.Code == defaultLang) ? defaultLang : "en";
|
||||
var choices = langService.GetLanguages().Select(o => new Format() { Name = o.DisplayName, Value = o.Code }).ToArray();
|
||||
var chosen = choices.FirstOrDefault(f => f.Value == defaultLang) ?? choices.FirstOrDefault();
|
||||
Languages = new SelectList(choices, nameof(chosen.Value), nameof(chosen.Name), chosen);
|
||||
DefaultLang = chosen.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -11,6 +11,7 @@ namespace BTCPayServer.Models.StoreViewModels
|
||||
{
|
||||
public class DerivationSchemeViewModel
|
||||
{
|
||||
|
||||
public DerivationSchemeViewModel()
|
||||
{
|
||||
}
|
||||
@ -42,6 +43,7 @@ namespace BTCPayServer.Models.StoreViewModels
|
||||
public string AccountKey { get; set; }
|
||||
public BTCPayNetwork Network { get; set; }
|
||||
public bool CanUseHotWallet { get; set; }
|
||||
public bool CanUseRPCImport { get; set; }
|
||||
|
||||
public RootedKeyPath GetAccountKeypath()
|
||||
{
|
||||
|
@ -1,6 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using BTCPayServer.ModelBinders;
|
||||
using BTCPayServer.Models.AppViewModels;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace BTCPayServer.Models.StoreViewModels
|
||||
@ -43,5 +44,8 @@ namespace BTCPayServer.Models.StoreViewModels
|
||||
public string PayButtonText { get; set; }
|
||||
public bool UseModal { get; set; }
|
||||
public bool JsonResponse { get; set; }
|
||||
public ListAppsViewModel.ListAppViewModel[] Apps { get; set; }
|
||||
public string AppIdEndpoint { get; set; } = "";
|
||||
public string AppChoiceKey { get; set; } = "";
|
||||
}
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user