Compare commits
15 Commits
Author | SHA1 | Date | |
---|---|---|---|
7ff095b79e | |||
64ed15f18f | |||
ca63c54dd5 | |||
a930ba1092 | |||
830993b5fe | |||
fd23fcb639 | |||
e8fdd83fc1 | |||
18326350a9 | |||
7bc19a2d41 | |||
e5a2981f0e | |||
ee5fac2ccc | |||
fcb22de3e9 | |||
584cee0fcd | |||
5be640b091 | |||
48edbe145e |
58
BTCPayServer.Client/Events/GreenFieldEvent.cs
Normal file
58
BTCPayServer.Client/Events/GreenFieldEvent.cs
Normal file
@ -0,0 +1,58 @@
|
||||
using System.Text;
|
||||
using NBitcoin;
|
||||
using NBitcoin.Crypto;
|
||||
using NBitcoin.DataEncoders;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace BTCPayServer.Client.Events
|
||||
{
|
||||
public class GreenFieldEvent<T> : IGreenFieldEvent
|
||||
{
|
||||
public string EventType { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public object Payload
|
||||
{
|
||||
get
|
||||
{
|
||||
return PayloadParsed;
|
||||
}
|
||||
set
|
||||
{
|
||||
{
|
||||
PayloadParsed = (T)value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[JsonProperty("payload")] public T PayloadParsed { get; set; }
|
||||
public string Signature { get; set; }
|
||||
|
||||
public void SetSignature(string url, Key key)
|
||||
{
|
||||
uint256 hash = new uint256(Hashes.SHA256(Encoding.UTF8.GetBytes(GetMessage(url))));
|
||||
Signature = Encoders.Hex.EncodeData(key.Sign(hash).ToDER());
|
||||
}
|
||||
|
||||
public bool VerifySignature(string url, PubKey key)
|
||||
{
|
||||
uint256 hash = new uint256(Hashes.SHA256(Encoding.UTF8.GetBytes(GetMessage(url))));
|
||||
return key.Verify(hash, new ECDSASignature(Encoders.Hex.DecodeData(Signature)));
|
||||
}
|
||||
|
||||
protected virtual string GetMessage(string url )
|
||||
{
|
||||
return Normalize($"{Normalize(url)}_{JsonConvert.SerializeObject(Payload)}");
|
||||
}
|
||||
|
||||
private string Normalize(string str)
|
||||
{
|
||||
return str
|
||||
.Replace(" ", "")
|
||||
.Replace("\t", "")
|
||||
.Replace("\n", "")
|
||||
.Replace("\r", "")
|
||||
.ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
}
|
13
BTCPayServer.Client/Events/IGreenFieldEvent.cs
Normal file
13
BTCPayServer.Client/Events/IGreenFieldEvent.cs
Normal file
@ -0,0 +1,13 @@
|
||||
using NBitcoin;
|
||||
|
||||
namespace BTCPayServer.Client.Events
|
||||
{
|
||||
public interface IGreenFieldEvent
|
||||
{
|
||||
string EventType { get; set; }
|
||||
object Payload { get; set; }
|
||||
string Signature { get; set; }
|
||||
void SetSignature(string url, Key key);
|
||||
bool VerifySignature(string url, PubKey key);
|
||||
}
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
using BTCPayServer.Client.Models;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
|
||||
namespace BTCPayServer.Client.Events
|
||||
{
|
||||
public class InvoiceStatusChangeEventPayload
|
||||
{
|
||||
public const string EventType = "invoice_status";
|
||||
public string InvoiceId { get; set; }
|
||||
[JsonConverter(typeof(StringEnumConverter))]
|
||||
public InvoiceStatus Status { get; set; }
|
||||
[JsonConverter(typeof(StringEnumConverter))]
|
||||
public InvoiceExceptionStatus AdditionalStatus { get; set; }
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using BTCPayServer.Client.JsonConverters;
|
||||
using BTCPayServer.JsonConverters;
|
||||
using Newtonsoft.Json;
|
||||
@ -14,6 +15,7 @@ namespace BTCPayServer.Client.Models
|
||||
public string Currency { get; set; }
|
||||
public JObject Metadata { get; set; }
|
||||
public CheckoutOptions Checkout { get; set; } = new CheckoutOptions();
|
||||
public List<WebhookSubscription> Webhooks { get; set; }
|
||||
|
||||
public class CheckoutOptions
|
||||
{
|
||||
|
@ -55,6 +55,7 @@ namespace BTCPayServer.Client.Models
|
||||
|
||||
public bool PayJoinEnabled { get; set; }
|
||||
public bool LightningPrivateRouteHints { get; set; }
|
||||
public List<WebhookSubscription> Webhooks { get; set; }
|
||||
|
||||
|
||||
[JsonExtensionData]
|
||||
|
@ -6,5 +6,7 @@ namespace BTCPayServer.Client.Models
|
||||
/// the id of the store
|
||||
/// </summary>
|
||||
public string Id { get; set; }
|
||||
|
||||
public string EventKey { get; set; }
|
||||
}
|
||||
}
|
||||
|
15
BTCPayServer.Client/Models/WebhookSubscription.cs
Normal file
15
BTCPayServer.Client/Models/WebhookSubscription.cs
Normal file
@ -0,0 +1,15 @@
|
||||
using System;
|
||||
|
||||
namespace BTCPayServer.Client.Models
|
||||
{
|
||||
public class WebhookSubscription
|
||||
{
|
||||
public string EventType { get; set; }
|
||||
public Uri Url { get; set; }
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"{Url}_{EventType}";
|
||||
}
|
||||
}
|
||||
}
|
@ -16,7 +16,7 @@ namespace BTCPayServer.Tests
|
||||
{
|
||||
readonly IWebHost _Host = null;
|
||||
readonly CancellationTokenSource _Closed = new CancellationTokenSource();
|
||||
readonly Channel<JObject> _Requests = Channel.CreateUnbounded<JObject>();
|
||||
readonly Channel<(JObject, Uri)> _Requests = Channel.CreateUnbounded<(JObject, Uri)>();
|
||||
public CustomServer()
|
||||
{
|
||||
var port = Utils.FreeTcpPort();
|
||||
@ -25,7 +25,9 @@ namespace BTCPayServer.Tests
|
||||
{
|
||||
app.Run(async req =>
|
||||
{
|
||||
await _Requests.Writer.WriteAsync(JsonConvert.DeserializeObject<JObject>(await new StreamReader(req.Request.Body).ReadToEndAsync()), _Closed.Token);
|
||||
await _Requests.Writer.WriteAsync(
|
||||
(JsonConvert.DeserializeObject<JObject>(await new StreamReader(req.Request.Body).ReadToEndAsync()),
|
||||
new Uri(req.Request.GetCurrentUrl())), _Closed.Token);
|
||||
req.Response.StatusCode = 200;
|
||||
});
|
||||
})
|
||||
@ -40,18 +42,18 @@ namespace BTCPayServer.Tests
|
||||
return new Uri(_Host.ServerFeatures.Get<IServerAddressesFeature>().Addresses.First());
|
||||
}
|
||||
|
||||
public async Task<JObject> GetNextRequest()
|
||||
public async Task<(JObject,Uri)> GetNextRequest()
|
||||
{
|
||||
using (CancellationTokenSource cancellation = new CancellationTokenSource(2000000))
|
||||
{
|
||||
try
|
||||
{
|
||||
JObject req = null;
|
||||
(JObject, Uri) req = (null, null);
|
||||
while (!await _Requests.Reader.WaitToReadAsync(cancellation.Token) ||
|
||||
!_Requests.Reader.TryRead(out req))
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
return req;
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
|
@ -1,13 +1,16 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Client.Events;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Controllers;
|
||||
using BTCPayServer.Events;
|
||||
using BTCPayServer.HostedServices;
|
||||
using BTCPayServer.JsonConverters;
|
||||
using BTCPayServer.Lightning;
|
||||
using BTCPayServer.Services;
|
||||
@ -815,6 +818,7 @@ namespace BTCPayServer.Tests
|
||||
[Trait("Integration", "Integration")]
|
||||
public async Task InvoiceTests()
|
||||
{
|
||||
|
||||
using (var tester = ServerTester.Create())
|
||||
{
|
||||
await tester.StartAsync();
|
||||
@ -877,7 +881,68 @@ namespace BTCPayServer.Tests
|
||||
//unarchive
|
||||
await client.UnarchiveInvoice(user.StoreId, invoice.Id);
|
||||
Assert.NotNull(await client.GetInvoice(user.StoreId, invoice.Id));
|
||||
using var customServer = new CustomServer();
|
||||
|
||||
//let's test invoice webhooks
|
||||
var store = await client.GetStore(user.StoreId);
|
||||
|
||||
//we can have webhooks on a store level
|
||||
Assert.Empty(store.Webhooks);
|
||||
store.Webhooks.Add(new WebhookSubscription()
|
||||
{
|
||||
Url = new Uri(customServer.GetUri(), "?src=store"),
|
||||
EventType = InvoiceStatusChangeEventPayload.EventType
|
||||
});
|
||||
var updateStore = JsonConvert.DeserializeObject<UpdateStoreRequest>(JsonConvert.SerializeObject(store));
|
||||
store = await client.UpdateStore(store.Id, updateStore);
|
||||
Assert.Single(store.Webhooks);
|
||||
|
||||
//we can verify that messages are authentic by using a dedicated key saved in the store
|
||||
var key = new PubKey(store.EventKey);
|
||||
|
||||
//status change
|
||||
var webhookedInvoice = await client.CreateInvoice(user.StoreId,
|
||||
new CreateInvoiceRequest()
|
||||
{
|
||||
Amount = 1,
|
||||
Currency = "USD",
|
||||
Webhooks = new List<WebhookSubscription>()
|
||||
{
|
||||
new WebhookSubscription()
|
||||
{
|
||||
Url = new Uri(customServer.GetUri(), "?src=invoice"),
|
||||
EventType = InvoiceStatusChangeEventPayload.EventType
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Assert.Single(webhookedInvoice.Webhooks);
|
||||
|
||||
var totalEvents = 0;
|
||||
var invResult = false;
|
||||
var storeResult = false;
|
||||
while (totalEvents != 2)
|
||||
{
|
||||
var webHooKResult = await customServer.GetNextRequest();
|
||||
if (webHooKResult.Item2.ParseQueryString().Get("src") == "invoice")
|
||||
{
|
||||
invResult = true;
|
||||
}
|
||||
if (webHooKResult.Item2.ParseQueryString().Get("src") == "store")
|
||||
{
|
||||
storeResult = true;
|
||||
}
|
||||
|
||||
Assert.Equal(InvoiceStatusChangeEventPayload.EventType, webHooKResult.Item1["eventType"].Value<string>());
|
||||
|
||||
var evt = webHooKResult.Item1.ToObject<GreenFieldEvent<InvoiceStatusChangeEventPayload>>();
|
||||
Assert.True(evt.VerifySignature(webHooKResult.Item2.ToString(), key));
|
||||
Assert.Equal(InvoiceStatus.New, evt.PayloadParsed.Status);
|
||||
Assert.Equal(InvoiceExceptionStatus.None, evt.PayloadParsed.AdditionalStatus);
|
||||
totalEvents++;
|
||||
}
|
||||
Assert.True(invResult);
|
||||
Assert.True(storeResult);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -92,6 +92,10 @@ namespace BTCPayServer.Tests
|
||||
{
|
||||
cts.Token.WaitHandle.WaitOne(500);
|
||||
}
|
||||
catch (EqualException) when (!cts.Token.IsCancellationRequested)
|
||||
{
|
||||
cts.Token.WaitHandle.WaitOne(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -221,7 +221,7 @@ namespace BTCPayServer.Tests
|
||||
bool valid = swagger.IsValid(schema, out errors);
|
||||
//the schema is not fully compliant to the spec. We ARE allowed to have multiple security schemas.
|
||||
var matchedError = errors.Where(error =>
|
||||
error.Path == "components.securitySchemes.Basic" && error.ErrorType == ErrorType.OneOf).ToList();
|
||||
new []{"components.securitySchemes.Basic", "components.securitySchemes.Webhook"}.Contains(error.Path) && error.ErrorType == ErrorType.OneOf).ToList();
|
||||
foreach (ValidationError validationError in matchedError)
|
||||
{
|
||||
errors.Remove(validationError);
|
||||
@ -968,9 +968,9 @@ namespace BTCPayServer.Tests
|
||||
while (!completed || !confirmed)
|
||||
{
|
||||
var request = await callbackServer.GetNextRequest();
|
||||
if (request.ContainsKey("event"))
|
||||
if (request.Item1.ContainsKey("event"))
|
||||
{
|
||||
var evtName = request["event"]["name"].Value<string>();
|
||||
var evtName = request.Item1["event"]["name"].Value<string>();
|
||||
switch (evtName)
|
||||
{
|
||||
case InvoiceEvent.Created:
|
||||
|
@ -227,6 +227,7 @@ namespace BTCPayServer.Controllers.GreenField
|
||||
AdditionalStatus = entity.ExceptionStatus,
|
||||
Currency = entity.Currency,
|
||||
Metadata = entity.Metadata.ToJObject(),
|
||||
Webhooks = entity.Webhooks,
|
||||
Checkout = new CreateInvoiceRequest.CheckoutOptions()
|
||||
{
|
||||
Expiration = entity.ExpirationTime - entity.InvoiceTime,
|
||||
|
@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Cors;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NBitcoin;
|
||||
|
||||
namespace BTCPayServer.Controllers.GreenField
|
||||
{
|
||||
@ -134,7 +135,9 @@ namespace BTCPayServer.Controllers.GreenField
|
||||
PaymentTolerance = storeBlob.PaymentTolerance,
|
||||
RedirectAutomatically = storeBlob.RedirectAutomatically,
|
||||
PayJoinEnabled = storeBlob.PayJoinEnabled,
|
||||
LightningPrivateRouteHints = storeBlob.LightningPrivateRouteHints
|
||||
LightningPrivateRouteHints = storeBlob.LightningPrivateRouteHints,
|
||||
Webhooks = storeBlob.Webhooks,
|
||||
EventKey = storeBlob.EventSigner.PubKey.ToHex()
|
||||
};
|
||||
}
|
||||
|
||||
@ -170,6 +173,7 @@ namespace BTCPayServer.Controllers.GreenField
|
||||
blob.RedirectAutomatically = restModel.RedirectAutomatically;
|
||||
blob.PayJoinEnabled = restModel.PayJoinEnabled;
|
||||
blob.LightningPrivateRouteHints = restModel.LightningPrivateRouteHints;
|
||||
blob.Webhooks = restModel.Webhooks;
|
||||
model.SetStoreBlob(blob);
|
||||
}
|
||||
|
||||
|
@ -41,8 +41,7 @@ namespace BTCPayServer.Controllers
|
||||
_cachedServerSettings = cachedServerSettings;
|
||||
_fileProvider = webHostEnvironment.WebRootFileProvider;
|
||||
SignInManager = signInManager;
|
||||
}
|
||||
|
||||
}
|
||||
private async Task<ViewResult> GoToApp(string appId, AppType? appType)
|
||||
{
|
||||
if (appType.HasValue && !string.IsNullOrEmpty(appId))
|
||||
|
@ -92,7 +92,7 @@ namespace BTCPayServer.Controllers
|
||||
var details = InvoicePopulatePayments(invoice);
|
||||
model.CryptoPayments = details.CryptoPayments;
|
||||
model.Payments = details.Payments;
|
||||
|
||||
model.Webhooks = invoice.Webhooks;
|
||||
return View(model);
|
||||
}
|
||||
|
||||
|
@ -164,9 +164,11 @@ namespace BTCPayServer.Controllers
|
||||
if (invoice.Metadata != null)
|
||||
entity.Metadata = InvoiceMetadata.FromJObject(invoice.Metadata);
|
||||
invoice.Checkout ??= new CreateInvoiceRequest.CheckoutOptions();
|
||||
invoice.Webhooks ??= new List<WebhookSubscription>();
|
||||
entity.Currency = invoice.Currency;
|
||||
entity.Price = invoice.Amount;
|
||||
entity.SpeedPolicy = invoice.Checkout.SpeedPolicy ?? store.SpeedPolicy;
|
||||
entity.Webhooks = invoice.Webhooks;
|
||||
IPaymentFilter excludeFilter = null;
|
||||
if (invoice.Checkout.PaymentMethods != null)
|
||||
{
|
||||
@ -197,7 +199,6 @@ namespace BTCPayServer.Controllers
|
||||
}
|
||||
entity.Status = InvoiceStatus.New;
|
||||
HashSet<CurrencyPair> currencyPairsToFetch = new HashSet<CurrencyPair>();
|
||||
var rules = storeBlob.GetRateRules(_NetworkProvider);
|
||||
var excludeFilter = storeBlob.GetExcludedPaymentMethods(); // Here we can compose filters from other origin with PaymentFilter.Any()
|
||||
if (invoicePaymentMethodFilter != null)
|
||||
{
|
||||
|
@ -16,6 +16,7 @@ using BTCPayServer.Services.Stores;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Cors;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NBitcoin;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using NicolasDorier.RateLimits;
|
||||
|
||||
@ -162,18 +163,45 @@ namespace BTCPayServer.Controllers
|
||||
[HttpGet]
|
||||
[Route("{storeId}/integrations")]
|
||||
[Route("{storeId}/integrations/shopify")]
|
||||
public IActionResult Integrations()
|
||||
[Route("{storeId}/integrations/webhooks")]
|
||||
public async Task<IActionResult> Integrations([FromServices] StoreRepository storeRepository)
|
||||
{
|
||||
var blob = CurrentStore.GetStoreBlob();
|
||||
|
||||
var vm = new IntegrationsViewModel {Shopify = blob.Shopify};
|
||||
|
||||
var vm = new IntegrationsViewModel {Shopify = blob.Shopify, EventPublicKey = blob.EventSigner.ToHex(), Webhooks = blob.Webhooks};
|
||||
|
||||
return View("Integrations", vm);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Route("{storeId}/integrations/webhooks")]
|
||||
public async Task<IActionResult> UpdateWebhooks([FromServices] IHttpClientFactory clientFactory,
|
||||
IntegrationsViewModel vm, string command = "")
|
||||
{
|
||||
switch (command.ToLowerInvariant())
|
||||
{
|
||||
case "add":
|
||||
vm.Webhooks.Add(new WebhookSubscription());
|
||||
return View("Integrations", vm);
|
||||
case string c when c.StartsWith("remove:", StringComparison.InvariantCultureIgnoreCase):
|
||||
var index = int.Parse(c.Substring(command.IndexOf(":", StringComparison.InvariantCultureIgnoreCase) + 1), CultureInfo.InvariantCulture);
|
||||
vm.Webhooks.RemoveAt(index);
|
||||
return View("Integrations", vm);
|
||||
case "save":
|
||||
var blob = CurrentStore.GetStoreBlob();
|
||||
blob.Webhooks = vm.Webhooks.Where(subscription => subscription.Url != null).ToList();
|
||||
var store = CurrentStore;
|
||||
store.SetStoreBlob(blob);
|
||||
await _Repo.UpdateStore(store);
|
||||
TempData[WellKnownTempData.SuccessMessage] = "Webhooks saved";
|
||||
break;
|
||||
}
|
||||
return RedirectToAction(nameof(Integrations), new {storeId = CurrentStore.Id});
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Route("{storeId}/integrations/shopify")]
|
||||
public async Task<IActionResult> Integrations([FromServices] IHttpClientFactory clientFactory,
|
||||
public async Task<IActionResult> UpdateShopify([FromServices] IHttpClientFactory clientFactory,
|
||||
IntegrationsViewModel vm, string command = "", string exampleUrl = "")
|
||||
{
|
||||
if (!string.IsNullOrEmpty(exampleUrl))
|
||||
|
@ -12,6 +12,7 @@ using BTCPayServer.Rating;
|
||||
using BTCPayServer.Services.Mails;
|
||||
using BTCPayServer.Services.Rates;
|
||||
using BTCPayServer.Services.Shopify.Models;
|
||||
using NBitcoin;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace BTCPayServer.Data
|
||||
@ -25,8 +26,14 @@ namespace BTCPayServer.Data
|
||||
PaymentTolerance = 0;
|
||||
ShowRecommendedFee = true;
|
||||
RecommendedFeeBlockTarget = 1;
|
||||
EventSigner = new Key();
|
||||
Hints = new StoreHints {Wallet = true, Lightning = true};
|
||||
}
|
||||
|
||||
public Key EventSigner { get; set; }
|
||||
public List<WebhookSubscription> Webhooks { get; set; } = new List<WebhookSubscription>();
|
||||
|
||||
|
||||
public ShopifySettings Shopify { get; set; }
|
||||
|
||||
[Obsolete("Use NetworkFeeMode instead")]
|
||||
|
@ -1,14 +1,19 @@
|
||||
using System;
|
||||
|
||||
namespace BTCPayServer.Events
|
||||
{
|
||||
public class InvoiceIPNEvent
|
||||
public class BitPayInvoiceIPNEvent
|
||||
{
|
||||
public InvoiceIPNEvent(string invoiceId, int? eventCode, string name)
|
||||
public BitPayInvoiceIPNEvent(string invoiceId, int? eventCode, string name)
|
||||
{
|
||||
InvoiceId = invoiceId;
|
||||
EventCode = eventCode;
|
||||
Name = name;
|
||||
Timestamp= DateTimeOffset.UtcNow;;
|
||||
}
|
||||
|
||||
public DateTimeOffset Timestamp { get; set; }
|
||||
|
||||
public int? EventCode { get; set; }
|
||||
public string Name { get; set; }
|
||||
|
@ -4,13 +4,15 @@ namespace BTCPayServer.Events
|
||||
{
|
||||
public class InvoiceDataChangedEvent
|
||||
{
|
||||
|
||||
public InvoiceDataChangedEvent(InvoiceEntity invoice)
|
||||
{
|
||||
InvoiceId = invoice.Id;
|
||||
State = invoice.GetInvoiceState();
|
||||
Invoice = invoice;
|
||||
}
|
||||
public string InvoiceId { get; }
|
||||
public InvoiceState State { get; }
|
||||
|
||||
public readonly InvoiceEntity Invoice;
|
||||
public string InvoiceId => Invoice.Id;
|
||||
public InvoiceState State => Invoice.GetInvoiceState();
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
|
@ -288,7 +288,8 @@ namespace BTCPayServer
|
||||
"://",
|
||||
request.Host.ToUriComponent(),
|
||||
request.PathBase.ToUriComponent(),
|
||||
request.Path.ToUriComponent());
|
||||
request.Path.ToUriComponent(),
|
||||
request.QueryString.ToUriComponent());
|
||||
}
|
||||
|
||||
public static string GetCurrentPath(this HttpRequest request)
|
||||
|
@ -22,7 +22,7 @@ using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace BTCPayServer.HostedServices
|
||||
{
|
||||
public class InvoiceNotificationManager : IHostedService
|
||||
public class BitPayIPNManager : IHostedService
|
||||
{
|
||||
readonly HttpClient _Client;
|
||||
|
||||
@ -33,7 +33,7 @@ namespace BTCPayServer.HostedServices
|
||||
get; set;
|
||||
}
|
||||
|
||||
public InvoicePaymentNotificationEventWrapper Notification
|
||||
public BitPayInvoicePaymentNotificationEventWrapper Notification
|
||||
{
|
||||
get; set;
|
||||
}
|
||||
@ -45,7 +45,7 @@ namespace BTCPayServer.HostedServices
|
||||
private readonly EmailSenderFactory _EmailSenderFactory;
|
||||
private readonly StoreRepository _StoreRepository;
|
||||
|
||||
public InvoiceNotificationManager(
|
||||
public BitPayIPNManager(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IBackgroundJobClient jobClient,
|
||||
EventAggregator eventAggregator,
|
||||
@ -64,7 +64,7 @@ namespace BTCPayServer.HostedServices
|
||||
async Task Notify(InvoiceEntity invoice, InvoiceEvent invoiceEvent, bool extendedNotification)
|
||||
{
|
||||
var dto = invoice.EntityToDTO();
|
||||
var notification = new InvoicePaymentNotificationEventWrapper()
|
||||
var notification = new BitPayInvoicePaymentNotificationEventWrapper()
|
||||
{
|
||||
Data = new InvoicePaymentNotification()
|
||||
{
|
||||
@ -84,7 +84,7 @@ namespace BTCPayServer.HostedServices
|
||||
ExchangeRates = dto.ExchangeRates,
|
||||
OrderId = dto.OrderId
|
||||
},
|
||||
Event = new InvoicePaymentNotificationEvent()
|
||||
Event = new BitPayInvoicePaymentNotificationEvent()
|
||||
{
|
||||
Code = invoiceEvent.EventCode,
|
||||
Name = invoiceEvent.Name
|
||||
@ -149,13 +149,13 @@ namespace BTCPayServer.HostedServices
|
||||
{
|
||||
var job = NBitcoin.JsonConverters.Serializer.ToObject<ScheduledJob>(invoiceData);
|
||||
bool reschedule = false;
|
||||
var aggregatorEvent = new InvoiceIPNEvent(job.Notification.Data.Id, job.Notification.Event.Code, job.Notification.Event.Name);
|
||||
var aggregatorEvent = new BitPayInvoiceIPNEvent(job.Notification.Data.Id, job.Notification.Event.Code, job.Notification.Event.Name);
|
||||
try
|
||||
{
|
||||
HttpResponseMessage response = await SendNotification(job.Notification, cancellationToken);
|
||||
reschedule = !response.IsSuccessStatusCode;
|
||||
aggregatorEvent.Error = reschedule ? $"Unexpected return code: {(int)response.StatusCode}" : null;
|
||||
_EventAggregator.Publish<InvoiceIPNEvent>(aggregatorEvent);
|
||||
_EventAggregator.Publish<BitPayInvoiceIPNEvent>(aggregatorEvent);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
@ -167,7 +167,7 @@ namespace BTCPayServer.HostedServices
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
aggregatorEvent.Error = "Timeout";
|
||||
_EventAggregator.Publish<InvoiceIPNEvent>(aggregatorEvent);
|
||||
_EventAggregator.Publish<BitPayInvoiceIPNEvent>(aggregatorEvent);
|
||||
reschedule = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@ -183,7 +183,7 @@ namespace BTCPayServer.HostedServices
|
||||
string message = String.Join(',', messages.ToArray());
|
||||
|
||||
aggregatorEvent.Error = $"Unexpected error: {message}";
|
||||
_EventAggregator.Publish<InvoiceIPNEvent>(aggregatorEvent);
|
||||
_EventAggregator.Publish<BitPayInvoiceIPNEvent>(aggregatorEvent);
|
||||
}
|
||||
|
||||
job.TryCount++;
|
||||
@ -195,17 +195,17 @@ namespace BTCPayServer.HostedServices
|
||||
}
|
||||
}
|
||||
|
||||
public class InvoicePaymentNotificationEvent
|
||||
public class BitPayInvoicePaymentNotificationEvent
|
||||
{
|
||||
[JsonProperty("code")]
|
||||
public int Code { get; set; }
|
||||
[JsonProperty("name")]
|
||||
public string Name { get; set; }
|
||||
}
|
||||
public class InvoicePaymentNotificationEventWrapper
|
||||
public class BitPayInvoicePaymentNotificationEventWrapper
|
||||
{
|
||||
[JsonProperty("event")]
|
||||
public InvoicePaymentNotificationEvent Event { get; set; }
|
||||
public BitPayInvoicePaymentNotificationEvent Event { get; set; }
|
||||
[JsonProperty("data")]
|
||||
public InvoicePaymentNotification Data { get; set; }
|
||||
[JsonProperty("extendedNotification")]
|
||||
@ -215,7 +215,7 @@ namespace BTCPayServer.HostedServices
|
||||
}
|
||||
|
||||
readonly Encoding UTF8 = new UTF8Encoding(false);
|
||||
private async Task<HttpResponseMessage> SendNotification(InvoicePaymentNotificationEventWrapper notification, CancellationToken cancellationToken)
|
||||
private async Task<HttpResponseMessage> SendNotification(BitPayInvoicePaymentNotificationEventWrapper notification, CancellationToken cancellationToken)
|
||||
{
|
||||
var request = new HttpRequestMessage();
|
||||
request.Method = HttpMethod.Post;
|
||||
@ -226,8 +226,7 @@ namespace BTCPayServer.HostedServices
|
||||
if (notification.ExtendedNotification)
|
||||
{
|
||||
jobj.Remove("extendedNotification");
|
||||
jobj.Remove("notificationURL");
|
||||
notificationString = jobj.ToString();
|
||||
jobj.Remove("notificationURL"); notificationString = jobj.ToString();
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -311,13 +310,13 @@ namespace BTCPayServer.HostedServices
|
||||
{
|
||||
leases.Add(_EventAggregator.Subscribe<InvoiceEvent>(async e =>
|
||||
{
|
||||
if (e.Invoice.NotificationURL is null && !string.IsNullOrEmpty(e.Invoice.NotificationEmail))
|
||||
{
|
||||
return;
|
||||
}
|
||||
var invoice = await _InvoiceRepository.GetInvoice(e.Invoice.Id);
|
||||
if (invoice == null)
|
||||
return;
|
||||
List<Task> tasks = new List<Task>();
|
||||
|
||||
// Awaiting this later help make sure invoices should arrive in order
|
||||
tasks.Add(SaveEvent(invoice.Id, e, InvoiceEventData.EventSeverity.Info));
|
||||
|
||||
// we need to use the status in the event and not in the invoice. The invoice might now be in another status.
|
||||
if (invoice.FullNotifications)
|
||||
@ -346,29 +345,10 @@ namespace BTCPayServer.HostedServices
|
||||
}));
|
||||
|
||||
|
||||
leases.Add(_EventAggregator.Subscribe<InvoiceDataChangedEvent>(async e =>
|
||||
{
|
||||
await SaveEvent(e.InvoiceId, e, InvoiceEventData.EventSeverity.Info);
|
||||
}));
|
||||
|
||||
|
||||
leases.Add(_EventAggregator.Subscribe<InvoiceStopWatchedEvent>(async e =>
|
||||
{
|
||||
await SaveEvent(e.InvoiceId, e, InvoiceEventData.EventSeverity.Info);
|
||||
}));
|
||||
|
||||
leases.Add(_EventAggregator.Subscribe<InvoiceIPNEvent>(async e =>
|
||||
{
|
||||
await SaveEvent(e.InvoiceId, e, string.IsNullOrEmpty(e.Error)? InvoiceEventData.EventSeverity.Success: InvoiceEventData.EventSeverity.Error);
|
||||
}));
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task SaveEvent(string invoiceId, object evt, InvoiceEventData.EventSeverity severity)
|
||||
{
|
||||
return _InvoiceRepository.AddInvoiceEvent(invoiceId, evt, severity);
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
257
BTCPayServer/HostedServices/GreenFieldWebhookManager.cs
Normal file
257
BTCPayServer/HostedServices/GreenFieldWebhookManager.cs
Normal file
@ -0,0 +1,257 @@
|
||||
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.Client.Events;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Events;
|
||||
using BTCPayServer.Services;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace BTCPayServer.HostedServices
|
||||
{
|
||||
public class GreenFieldWebhookManager : EventHostedServiceBase
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IBackgroundJobClient _backgroundJobClient;
|
||||
private readonly StoreRepository _storeRepository;
|
||||
private readonly MvcNewtonsoftJsonOptions _jsonOptions;
|
||||
private readonly EventAggregator _eventAggregator;
|
||||
|
||||
public GreenFieldWebhookManager(EventAggregator eventAggregator, IHttpClientFactory httpClientFactory,
|
||||
IBackgroundJobClient backgroundJobClient, StoreRepository storeRepository,
|
||||
MvcNewtonsoftJsonOptions jsonOptions) : base(
|
||||
eventAggregator)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_backgroundJobClient = backgroundJobClient;
|
||||
_storeRepository = storeRepository;
|
||||
_jsonOptions = jsonOptions;
|
||||
_eventAggregator = eventAggregator;
|
||||
}
|
||||
|
||||
public const string OnionNamedClient = "greenfield-webhook.onion";
|
||||
public const string ClearnetNamedClient = "greenfield-webhook.clearnet";
|
||||
|
||||
protected override void SubscribeToEvents()
|
||||
{
|
||||
base.SubscribeToEvents();
|
||||
Subscribe<InvoiceDataChangedEvent>();
|
||||
Subscribe<InvoiceEvent>();
|
||||
Subscribe<QueuedGreenFieldWebHook>();
|
||||
}
|
||||
|
||||
private HttpClient GetClient(Uri uri)
|
||||
{
|
||||
return _httpClientFactory.CreateClient(uri.IsOnion() ? OnionNamedClient : ClearnetNamedClient);
|
||||
}
|
||||
|
||||
readonly Dictionary<string, Task> _queuedSendingTasks = new Dictionary<string, Task>();
|
||||
|
||||
/// <summary>
|
||||
/// Will make sure only one callback is called at once on the same key
|
||||
/// </summary>
|
||||
/// <param name="key"></param>
|
||||
/// <param name="sendRequest"></param>
|
||||
/// <returns></returns>
|
||||
private async Task Enqueue(string key, Func<Task> sendRequest)
|
||||
{
|
||||
Task sending = null;
|
||||
lock (_queuedSendingTasks)
|
||||
{
|
||||
if (_queuedSendingTasks.TryGetValue(key, out var executing))
|
||||
{
|
||||
var completion = new TaskCompletionSource<bool>();
|
||||
sending = completion.Task;
|
||||
_queuedSendingTasks.Remove(key);
|
||||
_queuedSendingTasks.Add(key, sending);
|
||||
_ = executing.ContinueWith(_ =>
|
||||
{
|
||||
sendRequest()
|
||||
.ContinueWith(t =>
|
||||
{
|
||||
if (t.Status == TaskStatus.RanToCompletion)
|
||||
{
|
||||
completion.TrySetResult(true);
|
||||
}
|
||||
|
||||
if (t.Status == TaskStatus.Faulted)
|
||||
{
|
||||
completion.TrySetException(t.Exception);
|
||||
}
|
||||
|
||||
if (t.Status == TaskStatus.Canceled)
|
||||
{
|
||||
completion.TrySetCanceled();
|
||||
}
|
||||
}, TaskScheduler.Default);
|
||||
}, TaskScheduler.Default);
|
||||
}
|
||||
else
|
||||
{
|
||||
sending = sendRequest();
|
||||
_queuedSendingTasks.Add(key, sending);
|
||||
}
|
||||
|
||||
_ = sending.ContinueWith(o =>
|
||||
{
|
||||
lock (_queuedSendingTasks)
|
||||
{
|
||||
_queuedSendingTasks.TryGetValue(key, out var executing2);
|
||||
if (executing2 == sending)
|
||||
_queuedSendingTasks.Remove(key);
|
||||
}
|
||||
}, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
await sending;
|
||||
}
|
||||
|
||||
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
|
||||
{
|
||||
switch (evt)
|
||||
{
|
||||
case InvoiceEvent e
|
||||
when new[] {InvoiceEvent.MarkedCompleted, InvoiceEvent.MarkedInvalid, InvoiceEvent.Created}
|
||||
.Contains(e.Name):
|
||||
await HandleInvoiceStatusChange(e.Invoice);
|
||||
break;
|
||||
case InvoiceDataChangedEvent e:
|
||||
await HandleInvoiceStatusChange(e.Invoice);
|
||||
break;
|
||||
case QueuedGreenFieldWebHook e:
|
||||
_ = Enqueue(e.Grouping, () => Send(e, cancellationToken));
|
||||
break;
|
||||
}
|
||||
|
||||
await base.ProcessEvent(evt, cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
private async Task HandleInvoiceStatusChange(InvoiceEntity invoice)
|
||||
{
|
||||
var state = invoice.GetInvoiceState();
|
||||
var blob = (await _storeRepository.FindStore(invoice.StoreId)).GetStoreBlob();
|
||||
var key = blob.EventSigner;
|
||||
var validWebhooks = blob.Webhooks.Concat(invoice.Webhooks ?? new List<WebhookSubscription>()).Where(
|
||||
subscription =>
|
||||
ValidWebhookForEvent(subscription, InvoiceStatusChangeEventPayload.EventType)).ToList();
|
||||
if (validWebhooks.Any())
|
||||
{
|
||||
var payload = new InvoiceStatusChangeEventPayload()
|
||||
{
|
||||
Status = state.Status, AdditionalStatus = state.ExceptionStatus, InvoiceId = invoice.Id
|
||||
};
|
||||
|
||||
foreach (WebhookSubscription webhookSubscription in validWebhooks)
|
||||
{
|
||||
var webHook = new GreenFieldEvent<InvoiceStatusChangeEventPayload>()
|
||||
{
|
||||
EventType = InvoiceStatusChangeEventPayload.EventType, Payload = payload
|
||||
};
|
||||
webHook.SetSignature(webhookSubscription.Url.ToString(), key);
|
||||
_eventAggregator.Publish(new QueuedGreenFieldWebHook()
|
||||
{
|
||||
Event = webHook,
|
||||
Subscription = webhookSubscription,
|
||||
Grouping = webhookSubscription.ToString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Send(QueuedGreenFieldWebHook e, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = GetClient(e.Subscription.Url);
|
||||
var timestamp = DateTimeOffset.UtcNow;
|
||||
var request = new HttpRequestMessage
|
||||
{
|
||||
Method = HttpMethod.Post,
|
||||
RequestUri = e.Subscription.Url,
|
||||
Content = new StringContent(
|
||||
JsonConvert.SerializeObject(e.Event, Formatting.Indented, _jsonOptions.SerializerSettings),
|
||||
Encoding.UTF8, "application/json")
|
||||
};
|
||||
var webhookEventResultError = "";
|
||||
try
|
||||
{
|
||||
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(TimeSpan.FromMinutes(1.0));
|
||||
var result = await client.SendAsync(request, cts.Token);
|
||||
if (result.IsSuccessStatusCode)
|
||||
{
|
||||
_eventAggregator.Publish(new GreenFieldWebhookResultEvent()
|
||||
{
|
||||
Error = webhookEventResultError, Hook = e, Timestamp = timestamp
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
webhookEventResultError = $"Failure HTTP status code: {(int)result.StatusCode}";
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// When the JobClient will be persistent, this will reschedule the job for after reboot
|
||||
_backgroundJobClient.Schedule((cancellation) => Send(e, cancellation), TimeSpan.FromMinutes(10.0));
|
||||
return;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
webhookEventResultError = "Timeout";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
List<string> messages = new List<string>();
|
||||
while (ex != null)
|
||||
{
|
||||
messages.Add(ex.Message);
|
||||
ex = ex.InnerException;
|
||||
}
|
||||
|
||||
string message = string.Join(',', messages.ToArray());
|
||||
|
||||
webhookEventResultError = $"Unexpected error: {message}";
|
||||
}
|
||||
|
||||
e.TryCount++;
|
||||
_eventAggregator.Publish(new GreenFieldWebhookResultEvent()
|
||||
{
|
||||
Error =
|
||||
$"{webhookEventResultError} (Tried {Math.Min(e.TryCount, QueuedGreenFieldWebHook.MaxTry)}/{QueuedGreenFieldWebHook.MaxTry})",
|
||||
Hook = e,
|
||||
Timestamp = timestamp
|
||||
});
|
||||
|
||||
if (e.TryCount <= QueuedGreenFieldWebHook.MaxTry)
|
||||
_backgroundJobClient.Schedule((cancellation) => Send(e, cancellation), TimeSpan.FromMinutes(10.0));
|
||||
}
|
||||
|
||||
public bool ValidWebhookForEvent(WebhookSubscription webhookSubscription, string eventType)
|
||||
{
|
||||
return eventType.StartsWith(webhookSubscription.EventType, StringComparison.InvariantCultureIgnoreCase);
|
||||
}
|
||||
|
||||
public class QueuedGreenFieldWebHook
|
||||
{
|
||||
public string Grouping { get; set; }
|
||||
public WebhookSubscription Subscription { get; set; }
|
||||
public IGreenFieldEvent Event { get; set; }
|
||||
public int TryCount { get; set; } = 0;
|
||||
public const int MaxTry = 6;
|
||||
public override string ToString()
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
78
BTCPayServer/HostedServices/InvoiceEventSaverService.cs
Normal file
78
BTCPayServer/HostedServices/InvoiceEventSaverService.cs
Normal file
@ -0,0 +1,78 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Client.Events;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Events;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
|
||||
namespace BTCPayServer.HostedServices
|
||||
{
|
||||
public class InvoiceEventSaverService : EventHostedServiceBase
|
||||
{
|
||||
private readonly InvoiceRepository _invoiceRepository;
|
||||
|
||||
public InvoiceEventSaverService(EventAggregator eventAggregator, InvoiceRepository invoiceRepository) : base(
|
||||
eventAggregator)
|
||||
{
|
||||
_invoiceRepository = invoiceRepository;
|
||||
}
|
||||
|
||||
protected override void SubscribeToEvents()
|
||||
{
|
||||
Subscribe<InvoiceDataChangedEvent>();
|
||||
Subscribe<InvoiceStopWatchedEvent>();
|
||||
Subscribe<BitPayInvoiceIPNEvent>();
|
||||
Subscribe<InvoiceEvent>();
|
||||
Subscribe<GreenFieldWebhookResultEvent>();
|
||||
}
|
||||
|
||||
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
|
||||
{
|
||||
switch (evt)
|
||||
{
|
||||
case InvoiceDataChangedEvent e:
|
||||
await SaveEvent(e.InvoiceId, e, InvoiceEventData.EventSeverity.Info);
|
||||
break;
|
||||
case InvoiceStopWatchedEvent e:
|
||||
await SaveEvent(e.InvoiceId, e, InvoiceEventData.EventSeverity.Info);
|
||||
break;
|
||||
case InvoiceEvent e:
|
||||
await SaveEvent(e.Invoice.Id, e, InvoiceEventData.EventSeverity.Info);
|
||||
break;
|
||||
case BitPayInvoiceIPNEvent e:
|
||||
await SaveEvent(e.InvoiceId, e,
|
||||
string.IsNullOrEmpty(e.Error)
|
||||
? InvoiceEventData.EventSeverity.Success
|
||||
: InvoiceEventData.EventSeverity.Error, e.Timestamp);
|
||||
break;
|
||||
case GreenFieldWebhookResultEvent e when e.Hook.Event is GreenFieldEvent<InvoiceStatusChangeEventPayload> greenFieldEvent:
|
||||
await SaveEvent(greenFieldEvent.PayloadParsed.InvoiceId, e,
|
||||
string.IsNullOrEmpty(e.Error)
|
||||
? InvoiceEventData.EventSeverity.Success
|
||||
: InvoiceEventData.EventSeverity.Error, e.Timestamp);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private Task SaveEvent(string invoiceId, object evt, InvoiceEventData.EventSeverity severity, DateTimeOffset? timeOffset = null)
|
||||
{
|
||||
return _invoiceRepository.AddInvoiceEvent(invoiceId, evt, severity, timeOffset);
|
||||
}
|
||||
}
|
||||
|
||||
public class GreenFieldWebhookResultEvent
|
||||
{
|
||||
public GreenFieldWebhookManager.QueuedGreenFieldWebHook Hook { get; set; }
|
||||
public string Error { get; set; }
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return string.IsNullOrEmpty(Error)
|
||||
? $"Webhook {Hook.Subscription.EventType} sent to {Hook.Subscription.Url}"
|
||||
: $"Error while sending webhook {Hook.Subscription.EventType} to {Hook.Subscription.Url}: {Error}";
|
||||
}
|
||||
|
||||
public DateTimeOffset Timestamp { get; set; }
|
||||
}
|
||||
}
|
@ -234,7 +234,12 @@ namespace BTCPayServer.Hosting
|
||||
services.AddScoped<NotificationSender>();
|
||||
|
||||
services.AddSingleton<IHostedService, NBXplorerWaiters>();
|
||||
services.AddSingleton<IHostedService, InvoiceNotificationManager>();
|
||||
services.AddSingleton<IHostedService, BitPayIPNManager>();
|
||||
services.AddSingleton<IHostedService, InvoiceEventSaverService>();
|
||||
services.AddSingleton<IHostedService, GreenFieldWebhookManager>();
|
||||
services.AddHttpClient(GreenFieldWebhookManager.OnionNamedClient)
|
||||
.ConfigureHttpClient(h => h.DefaultRequestHeaders.ConnectionClose = true);
|
||||
services.AddHttpClient(GreenFieldWebhookManager.ClearnetNamedClient);
|
||||
services.AddSingleton<IHostedService, InvoiceWatcher>();
|
||||
services.AddSingleton<IHostedService, RatesHostedService>();
|
||||
services.AddSingleton<IHostedService, BackgroundJobSchedulerHostedService>();
|
||||
@ -256,7 +261,7 @@ namespace BTCPayServer.Hosting
|
||||
|
||||
services.AddSingleton<INotificationHandler, InvoiceEventNotification.Handler>();
|
||||
services.AddSingleton<INotificationHandler, PayoutNotification.Handler>();
|
||||
|
||||
|
||||
services.AddShopify();
|
||||
#if DEBUG
|
||||
services.AddSingleton<INotificationHandler, JunkNotification.Handler>();
|
||||
|
@ -10,6 +10,7 @@ using BTCPayServer.Services.Stores;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NBitcoin;
|
||||
|
||||
namespace BTCPayServer.Hosting
|
||||
{
|
||||
@ -39,6 +40,12 @@ namespace BTCPayServer.Hosting
|
||||
{
|
||||
await Migrate(cancellationToken);
|
||||
var settings = (await _Settings.GetSettingAsync<MigrationSettings>()) ?? new MigrationSettings();
|
||||
if (!settings.StoreEventSignerCreatedCheck)
|
||||
{
|
||||
await StoreEventSignerCreatedCheck();
|
||||
settings.StoreEventSignerCreatedCheck = true;
|
||||
await _Settings.UpdateSetting(settings);
|
||||
}
|
||||
if (!settings.DeprecatedLightningConnectionStringCheck)
|
||||
{
|
||||
await DeprecatedLightningConnectionStringCheck();
|
||||
@ -231,5 +238,17 @@ retry:
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StoreEventSignerCreatedCheck()
|
||||
{
|
||||
await using var ctx = _DBContextFactory.CreateContext();
|
||||
foreach (var store in await ctx.Stores.ToArrayAsync())
|
||||
{
|
||||
var blob = store.GetStoreBlob();
|
||||
blob.EventSigner = new Key();
|
||||
store.SetStoreBlob(blob);
|
||||
}
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -59,7 +59,7 @@ namespace BTCPayServer.Models.InvoicingModels
|
||||
}
|
||||
|
||||
[Uri]
|
||||
[DisplayName("Notification Url")]
|
||||
[DisplayName("Invoice Status Change Webhook Url")]
|
||||
public string NotificationUrl
|
||||
{
|
||||
get; set;
|
||||
|
@ -60,6 +60,8 @@ namespace BTCPayServer.Models.InvoicingModels
|
||||
get; set;
|
||||
} = new List<CryptoPayment>();
|
||||
|
||||
public List<WebhookSubscription> Webhooks { get; set; }
|
||||
|
||||
public string State
|
||||
{
|
||||
get; set;
|
||||
|
@ -1,15 +1,13 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Services.Shopify.Models;
|
||||
using static BTCPayServer.Data.StoreBlob;
|
||||
|
||||
namespace BTCPayServer.Models.StoreViewModels
|
||||
{
|
||||
public class IntegrationsViewModel
|
||||
{
|
||||
public ShopifySettings Shopify { get; set; }
|
||||
public string EventPublicKey { get; set; }
|
||||
public List<WebhookSubscription> Webhooks { get; set; } = new List<WebhookSubscription>();
|
||||
}
|
||||
}
|
||||
|
@ -208,7 +208,7 @@ namespace BTCPayServer.Services.Invoices
|
||||
public BTCPayNetworkProvider Networks { get; set; }
|
||||
public const int InternalTagSupport_Version = 1;
|
||||
public const int GreenfieldInvoices_Version = 2;
|
||||
public const int Lastest_Version = 2;
|
||||
public const int Latest_Version = 2;
|
||||
public int Version { get; set; }
|
||||
public string Id
|
||||
{
|
||||
@ -464,6 +464,7 @@ namespace BTCPayServer.Services.Invoices
|
||||
public List<InvoiceEventData> Events { get; internal set; }
|
||||
public double PaymentTolerance { get; set; }
|
||||
public bool Archived { get; set; }
|
||||
public List<WebhookSubscription> Webhooks { get; set; } = new List<WebhookSubscription>();
|
||||
|
||||
public bool IsExpired()
|
||||
{
|
||||
|
@ -64,7 +64,7 @@ retry:
|
||||
return new InvoiceEntity()
|
||||
{
|
||||
Networks = _Networks,
|
||||
Version = InvoiceEntity.Lastest_Version,
|
||||
Version = InvoiceEntity.Latest_Version,
|
||||
InvoiceTime = DateTimeOffset.UtcNow,
|
||||
Metadata = new InvoiceMetadata()
|
||||
};
|
||||
@ -306,17 +306,17 @@ retry:
|
||||
}
|
||||
}
|
||||
|
||||
public async Task AddInvoiceEvent(string invoiceId, object evt, InvoiceEventData.EventSeverity severity)
|
||||
public async Task AddInvoiceEvent(string invoiceId, object evt, InvoiceEventData.EventSeverity severity, DateTimeOffset? timestamp = null)
|
||||
{
|
||||
await using var context = _ContextFactory.CreateContext();
|
||||
await context.InvoiceEvents.AddAsync(new InvoiceEventData()
|
||||
{
|
||||
Severity = severity,
|
||||
InvoiceDataId = invoiceId,
|
||||
Message = evt.ToString(),
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
UniqueId = Encoders.Hex.EncodeData(RandomUtils.GetBytes(10))
|
||||
});
|
||||
{
|
||||
Severity = severity,
|
||||
InvoiceDataId = invoiceId,
|
||||
Message = evt.ToString(),
|
||||
Timestamp = timestamp ?? DateTimeOffset.UtcNow,
|
||||
UniqueId = Encoders.Hex.EncodeData(RandomUtils.GetBytes(10))
|
||||
});
|
||||
try
|
||||
{
|
||||
await context.SaveChangesAsync();
|
||||
|
@ -2,6 +2,7 @@ namespace BTCPayServer.Services
|
||||
{
|
||||
public class MigrationSettings
|
||||
{
|
||||
public bool StoreEventSignerCreatedCheck { get; set; }
|
||||
public bool UnreachableStoreCheck { get; set; }
|
||||
public bool DeprecatedLightningConnectionStringCheck { get; set; }
|
||||
public bool ConvertMultiplierToSpread { get; set; }
|
||||
|
@ -182,13 +182,6 @@ namespace BTCPayServer.Services.Stores
|
||||
public async Task<StoreData> CreateStore(string ownerId, string name)
|
||||
{
|
||||
var store = new StoreData() { StoreName = name };
|
||||
var blob = store.GetStoreBlob();
|
||||
blob.Hints = new Data.StoreBlob.StoreHints
|
||||
{
|
||||
Wallet = true,
|
||||
Lightning = true
|
||||
};
|
||||
store.SetStoreBlob(blob);
|
||||
await CreateStore(ownerId, store);
|
||||
return store;
|
||||
}
|
||||
|
@ -23,6 +23,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<redoc spec-url="@Url.ActionLink("Swagger")"></redoc>
|
||||
<script src="https://cdn.jsdelivr.net/npm/redoc@2.0.0-rc.24/bundles/redoc.standalone.js" integrity="sha384-ZO+OTQZMsYIcoraCBa8iJW/5b2O8K1ujHmRfOwSvpVBlHUeKq5t3/kh1p8JQJ99X" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/redoc@2.0.0-rc.45/bundles/redoc.standalone.js" crossorigin="anonymous"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -1,3 +1,4 @@
|
||||
@using BTCPayServer.Client.Models
|
||||
@model InvoiceDetailsModel
|
||||
@{
|
||||
ViewData["Title"] = "Invoice " + Model.Id;
|
||||
@ -99,14 +100,36 @@
|
||||
<th>Total fiat due</th>
|
||||
<td>@Model.Fiat</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Notification Url</th>
|
||||
<td>@Model.NotificationUrl</td>
|
||||
</tr>
|
||||
@if (!string.IsNullOrEmpty(Model.NotificationUrl))
|
||||
{
|
||||
<tr>
|
||||
<th>Notification Url</th>
|
||||
<td>@Model.NotificationUrl</td>
|
||||
</tr>
|
||||
}
|
||||
<tr>
|
||||
<th>Redirect Url</th>
|
||||
<td><a href="@Model.RedirectUrl">@Model.RedirectUrl</a></td>
|
||||
</tr>
|
||||
@if (Model.Webhooks?.Any() is true)
|
||||
{
|
||||
<tr>
|
||||
<th>Webhooks</th>
|
||||
<td>
|
||||
<ul class="list-group list-group-flush">
|
||||
@foreach (var webhook in Model.Webhooks)
|
||||
{
|
||||
<li class="list-group-item ">
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="@webhook.Url" target="_blank" class="text-truncate" style="max-width: 200px;" data-toggle="tooltip" title="@webhook.Url">@webhook.Url</a>
|
||||
<span class="text-muted">@webhook.EventType</span>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
|
@ -4,7 +4,7 @@
|
||||
ViewData.SetActivePageAndTitle(StoreNavPages.Integrations, "Integrations");
|
||||
}
|
||||
|
||||
<partial name="_StatusMessage" />
|
||||
<partial name="_StatusMessage"/>
|
||||
|
||||
@if (!ViewContext.ModelState.IsValid)
|
||||
{
|
||||
@ -17,23 +17,43 @@
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<partial name="Integrations/Shopify"/>
|
||||
|
||||
<partial name="Integrations/Shopify"/>
|
||||
<partial name="Integrations/Webhooks"/>
|
||||
<h4 class="mb-3 mt-5">
|
||||
Other Integrations
|
||||
</h4>
|
||||
<p>
|
||||
Take a look at documentation for the list of other integrations we support and the directions on how to enable them:
|
||||
<ul>
|
||||
<li><a href="https://docs.btcpayserver.org/WooCommerce/" target="_blank">WooCommerce</a></li>
|
||||
<li><a href="https://docs.btcpayserver.org/Drupal/" target="_blank">Drupal</a></li>
|
||||
<li><a href="https://docs.btcpayserver.org/Magento/" target="_blank">Magento</a></li>
|
||||
<li><a href="https://docs.btcpayserver.org/PrestaShop/" target="_blank">PrestaShop</a></li>
|
||||
<li>
|
||||
<a href="https://docs.btcpayserver.org/WooCommerce/" target="_blank">WooCommerce</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://docs.btcpayserver.org/Drupal/" target="_blank">Drupal</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://docs.btcpayserver.org/Magento/" target="_blank">Magento</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://docs.btcpayserver.org/PrestaShop/" target="_blank">PrestaShop</a>
|
||||
</li>
|
||||
</ul>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@section Scripts {
|
||||
@await Html.PartialAsync("_ValidationScriptsPartial")
|
||||
<style>
|
||||
.remove-btn {
|
||||
font-size: 1.5rem;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.remove-btn:hover {
|
||||
background-color: #CCCCCC;
|
||||
}
|
||||
</style>
|
||||
}
|
||||
|
@ -4,7 +4,7 @@
|
||||
var shopifyCredsSet = shopify?.IntegratedAt.HasValue is true;
|
||||
var shopifyUrl = shopify is null? null: !shopify?.ShopName?.Contains(".") is true ? $"https://{shopify.ShopName}.myshopify.com" : shopify.ShopName;
|
||||
}
|
||||
<form method="post" id="shopifyForm">
|
||||
<form method="post" id="shopifyForm" asp-action="UpdateShopify" asp-route-storeId="@this.Context.GetRouteValue("storeId")">
|
||||
<h4 class="mb-3">
|
||||
Shopify
|
||||
<a href="https://docs.btcpayserver.org/Shopify" target="_blank">
|
||||
|
55
BTCPayServer/Views/Stores/Integrations/Webhooks.cshtml
Normal file
55
BTCPayServer/Views/Stores/Integrations/Webhooks.cshtml
Normal file
@ -0,0 +1,55 @@
|
||||
@using BTCPayServer.Client.Events
|
||||
@model IntegrationsViewModel
|
||||
|
||||
<form method="post" asp-action="UpdateWebhooks" asp-route-storeId="@this.Context.GetRouteValue("storeId")">
|
||||
<div class="list-group mb-2 mt-5">
|
||||
<div class="list-group-item">
|
||||
<h4 class="mb-1">
|
||||
Webhooks
|
||||
<button type="submit" name="command" value="add" class="ml-1 btn btn-secondary btn-sm ">Add webhook</button>
|
||||
</h4>
|
||||
</div>
|
||||
@for (var index = 0; index < Model.Webhooks.Count; index++)
|
||||
{
|
||||
<div class="list-group-item p-0 pl-lg-2">
|
||||
<div class="row">
|
||||
<div class="col-sm-12 col-md-12 col-lg-10 py-2 ">
|
||||
<div class="form-group">
|
||||
<label asp-for="Webhooks[index].Url" class="control-label"></label>
|
||||
<input asp-for="Webhooks[index].Url" class="form-control"/>
|
||||
<span asp-validation-for="Webhooks[index].Url" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label asp-for="Webhooks[index].EventType"></label>
|
||||
<select asp-for="Webhooks[index].EventType" class="form-control">
|
||||
<option value="@InvoiceStatusChangeEventPayload.EventType">Invoice status change</option>
|
||||
</select>
|
||||
|
||||
<span asp-validation-for="Webhooks[index].EventType" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-12 col-md-12 col-lg-2 pull-right">
|
||||
<button type="submit" title="Remove webhook" name="command" value="@($"remove:{index}")"
|
||||
class="d-block d-lg-none d-xl-none btn btn-danger mb-2 ml-2">
|
||||
Remove
|
||||
</button>
|
||||
<button type="submit" title="Remove webhook" name="command" value="@($"remove:{index}")"
|
||||
class="d-none d-lg-block remove-btn text-decoration-none h-100 align-middle btn text-danger btn-link fa fa-times rounded-0 pull-right">
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="list-group-item border-top-0">
|
||||
<button type="submit" name="command" value="save" class="ml-1 btn btn-primary">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="mb-3 mt-5">
|
||||
Webhook Verification Key
|
||||
</h4>
|
||||
<p>When receiving webhook events from the Greenfield API related to this store, you can verify the signature against this public key.</p>
|
||||
<input type="text" class="form-control" readonly asp-for="EventPublicKey"/>
|
||||
|
||||
</form>
|
@ -506,6 +506,13 @@
|
||||
}
|
||||
],
|
||||
"description": "Additional settings to customize the checkout flow"
|
||||
},
|
||||
"webhooks": {
|
||||
"type": "array",
|
||||
"description": "A list of webhook subscriptions",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/WebhookSubscription"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -568,6 +575,68 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"x-webhooks": {
|
||||
"Invoice Status Changed": {
|
||||
"post": {
|
||||
"summary": "Invoice Status Changed",
|
||||
"description": "Information on when an invoice status has changed.",
|
||||
"operationId": "invoice_status",
|
||||
"tags": [
|
||||
"Invoices"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"Webhook": ["store"]
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"eventType": {
|
||||
"type": "string",
|
||||
"example": "invoice_status",
|
||||
"description": "The event type"
|
||||
},
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"invoiceId": {
|
||||
"type": "string",
|
||||
"example": "9dD1z86QWCy2rNPGT9KBtD",
|
||||
"description": "The invoice id"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/components/schemas/InvoiceStatus",
|
||||
"description": "The status of the invoice"
|
||||
},
|
||||
"additionalStatus": {
|
||||
"$ref": "#/components/schemas/InvoiceAdditionalStatus",
|
||||
"description": "a secondary status of the invoice"
|
||||
}
|
||||
}
|
||||
},
|
||||
"signature": {
|
||||
"type": "string",
|
||||
"example": "HwQQDD37sIhar3o1OcY2msxecHhdubCyliOp5qEpHm9YErvKGsFl2iVF8QEOafrIHM4INB27aGLPJkEtRc9s1r4=",
|
||||
"description": "A signature signed by store to verify the event was sent from your store."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Return a 200 status to indicate that the data was received successfully"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
{
|
||||
"name": "Invoices"
|
||||
|
@ -64,7 +64,12 @@
|
||||
"name": "Authorization",
|
||||
"in": "header",
|
||||
"scheme": "Basic"
|
||||
}
|
||||
},
|
||||
"Webhook":{
|
||||
"description": "BTCPay Server supports verifying webhooks through various generated private keys. Depending on the event, the private key can be found in the store, server, user, etc.\n\nThe signature for events is generated by hashing `{url}_{payload}`, which has been lowercased and all spaces, tabs and new lines stripped out, where `url` is the full url the HTTP POST request was sent to and `payload` is the `payload` json object in the body.",
|
||||
"name": "Webhooks",
|
||||
"type": "The public key is a Bitcoin public key in hex format."
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
|
@ -264,6 +264,11 @@
|
||||
"type": "string",
|
||||
"description": "The id of the store",
|
||||
"nullable": false
|
||||
},
|
||||
"eventKey": {
|
||||
"type": "string",
|
||||
"format": "hex",
|
||||
"description": "The public key used to sign webhooks related to this store."
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -377,6 +382,13 @@
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Should private route hints be included in the lightning payment of the checkout page."
|
||||
},
|
||||
"webhooks": {
|
||||
"type": "array",
|
||||
"description": "A list of webhook subscriptions",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/WebhookSubscription"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -0,0 +1,25 @@
|
||||
{
|
||||
"components": {
|
||||
"schemas": {
|
||||
"WebhookSubscription": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"eventType": {
|
||||
"type": "string",
|
||||
"example": "invoice_status",
|
||||
"description": "The event the webhook is subscribed to",
|
||||
"enum": [
|
||||
"invoice_status"
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"format": "url",
|
||||
"description": "The url the webhook HTTP POST request will be sent to"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user