From f3611ac693d4c55e5d0fb7a3a17bb6be0854d84e Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Fri, 6 Nov 2020 20:42:26 +0900 Subject: [PATCH] Add Webhooks in store's settings --- .../Models/WebhookDeliveryStatus.cs | 13 + BTCPayServer.Client/Models/WebhookEvent.cs | 21 ++ .../Models/WebhookEventType.cs | 16 + .../Models/WebhookInvoiceEvent.cs | 16 + .../Data/ApplicationDbContext.cs | 12 +- .../Data/InvoiceWebhookDeliveryData.cs | 26 ++ BTCPayServer.Data/Data/StoreWebhookData.cs | 30 ++ BTCPayServer.Data/Data/WebhookData.cs | 22 ++ BTCPayServer.Data/Data/WebhookDeliveryData.cs | 36 +++ .../Migrations/20201108054749_webhooks.cs | 115 +++++++ .../ApplicationDbContextModelSnapshot.cs | 117 +++++++ BTCPayServer.Tests/BTCPayServer.Tests.csproj | 1 + BTCPayServer.Tests/RawHttpServer.cs | 89 +++++ BTCPayServer.Tests/SeleniumTester.cs | 3 +- BTCPayServer.Tests/SeleniumTests.cs | 148 ++++++++- BTCPayServer.Tests/UnitTest1.cs | 1 - .../Controllers/InvoiceController.UI.cs | 49 +++ BTCPayServer/Controllers/InvoiceController.cs | 7 +- .../StoresController.Integrations.cs | 129 ++++++++ BTCPayServer/Controllers/StoresController.cs | 5 +- BTCPayServer/Data/WebhookDataExtensions.cs | 66 ++++ .../HostedServices/EventHostedServiceBase.cs | 7 +- .../WebhookNotificationManager.cs | 304 ++++++++++++++++++ BTCPayServer/Hosting/BTCPayServerServices.cs | 3 +- .../InvoicingModels/InvoiceDetailsModel.cs | 3 + .../StoreViewModels/EditWebhookViewModel.cs | 80 +++++ .../StoreViewModels/WebhooksViewModel.cs | 17 + .../Services/Invoices/InvoiceRepository.cs | 21 ++ .../Services/Stores/StoreRepository.cs | 114 ++++++- BTCPayServer/Views/Invoice/Invoice.cshtml | 64 +++- .../Views/Invoice/ListInvoices.cshtml | 3 +- .../ListInvoicesPaymentsPartial.cshtml | 4 +- .../Views/Stores/ModifyWebhook.cshtml | 157 +++++++++ BTCPayServer/Views/Stores/StoreNavPages.cs | 2 +- BTCPayServer/Views/Stores/UpdateStore.cshtml | 4 +- BTCPayServer/Views/Stores/Webhooks.cshtml | 46 +++ BTCPayServer/Views/Stores/_Nav.cshtml | 1 + BTCPayServer/wwwroot/main/site.js | 32 ++ 38 files changed, 1756 insertions(+), 28 deletions(-) create mode 100644 BTCPayServer.Client/Models/WebhookDeliveryStatus.cs create mode 100644 BTCPayServer.Client/Models/WebhookEvent.cs create mode 100644 BTCPayServer.Client/Models/WebhookEventType.cs create mode 100644 BTCPayServer.Client/Models/WebhookInvoiceEvent.cs create mode 100644 BTCPayServer.Data/Data/InvoiceWebhookDeliveryData.cs create mode 100644 BTCPayServer.Data/Data/StoreWebhookData.cs create mode 100644 BTCPayServer.Data/Data/WebhookData.cs create mode 100644 BTCPayServer.Data/Data/WebhookDeliveryData.cs create mode 100644 BTCPayServer.Data/Migrations/20201108054749_webhooks.cs create mode 100644 BTCPayServer.Tests/RawHttpServer.cs create mode 100644 BTCPayServer/Data/WebhookDataExtensions.cs create mode 100644 BTCPayServer/HostedServices/WebhookNotificationManager.cs create mode 100644 BTCPayServer/Models/StoreViewModels/EditWebhookViewModel.cs create mode 100644 BTCPayServer/Models/StoreViewModels/WebhooksViewModel.cs create mode 100644 BTCPayServer/Views/Stores/ModifyWebhook.cshtml create mode 100644 BTCPayServer/Views/Stores/Webhooks.cshtml diff --git a/BTCPayServer.Client/Models/WebhookDeliveryStatus.cs b/BTCPayServer.Client/Models/WebhookDeliveryStatus.cs new file mode 100644 index 000000000..dde4b52d4 --- /dev/null +++ b/BTCPayServer.Client/Models/WebhookDeliveryStatus.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace BTCPayServer.Client.Models +{ + public enum WebhookDeliveryStatus + { + Failed, + HttpError, + HttpSuccess + } +} diff --git a/BTCPayServer.Client/Models/WebhookEvent.cs b/BTCPayServer.Client/Models/WebhookEvent.cs new file mode 100644 index 000000000..0a27f01e8 --- /dev/null +++ b/BTCPayServer.Client/Models/WebhookEvent.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Linq; + +namespace BTCPayServer.Client.Models +{ + public class WebhookEvent + { + public string DeliveryId { get; set; } + public string OrignalDeliveryId { get; set; } + [JsonConverter(typeof(StringEnumConverter))] + public WebhookEventType Type { get; set; } + [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))] + public DateTimeOffset Timestamp { get; set; } + [JsonExtensionData] + public IDictionary AdditionalData { get; set; } + } +} diff --git a/BTCPayServer.Client/Models/WebhookEventType.cs b/BTCPayServer.Client/Models/WebhookEventType.cs new file mode 100644 index 000000000..a0644bf80 --- /dev/null +++ b/BTCPayServer.Client/Models/WebhookEventType.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace BTCPayServer.Client.Models +{ + public enum WebhookEventType + { + InvoiceCreated, + InvoiceReceivedPayment, + InvoicePaidInFull, + InvoiceExpired, + InvoiceConfirmed, + InvoiceInvalid + } +} diff --git a/BTCPayServer.Client/Models/WebhookInvoiceEvent.cs b/BTCPayServer.Client/Models/WebhookInvoiceEvent.cs new file mode 100644 index 000000000..8e6e09425 --- /dev/null +++ b/BTCPayServer.Client/Models/WebhookInvoiceEvent.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace BTCPayServer.Client.Models +{ + public class WebhookInvoiceEvent : WebhookEvent + { + [JsonProperty(Order = 1)] + public string StoreId { get; set; } + [JsonProperty(Order = 2)] + public string InvoiceId { get; set; } + } +} diff --git a/BTCPayServer.Data/Data/ApplicationDbContext.cs b/BTCPayServer.Data/Data/ApplicationDbContext.cs index 0e18da7de..fe857548a 100644 --- a/BTCPayServer.Data/Data/ApplicationDbContext.cs +++ b/BTCPayServer.Data/Data/ApplicationDbContext.cs @@ -63,6 +63,11 @@ namespace BTCPayServer.Data public DbSet U2FDevices { get; set; } public DbSet Notifications { get; set; } + public DbSet StoreWebhooks { get; set; } + public DbSet Webhooks { get; set; } + public DbSet WebhookDeliveries { get; set; } + public DbSet InvoiceWebhookDeliveries { get; set; } + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { var isConfigured = optionsBuilder.Options.Extensions.OfType().Any(); @@ -73,6 +78,7 @@ namespace BTCPayServer.Data protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); + Data.UserStore.OnModelCreating(builder); NotificationData.OnModelCreating(builder); InvoiceData.OnModelCreating(builder); PaymentData.OnModelCreating(builder); @@ -91,7 +97,11 @@ namespace BTCPayServer.Data PayoutData.OnModelCreating(builder); RefundData.OnModelCreating(builder); U2FDevice.OnModelCreating(builder); - + + Data.WebhookDeliveryData.OnModelCreating(builder); + Data.StoreWebhookData.OnModelCreating(builder); + Data.InvoiceWebhookDeliveryData.OnModelCreating(builder); + if (Database.IsSqlite() && !_designTime) { // SQLite does not have proper support for DateTimeOffset via Entity Framework Core, see the limitations diff --git a/BTCPayServer.Data/Data/InvoiceWebhookDeliveryData.cs b/BTCPayServer.Data/Data/InvoiceWebhookDeliveryData.cs new file mode 100644 index 000000000..186f02148 --- /dev/null +++ b/BTCPayServer.Data/Data/InvoiceWebhookDeliveryData.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Microsoft.EntityFrameworkCore; + +namespace BTCPayServer.Data +{ + public class InvoiceWebhookDeliveryData + { + public string InvoiceId { get; set; } + public InvoiceData Invoice { get; set; } + public string DeliveryId { get; set; } + public WebhookDeliveryData Delivery { get; set; } + internal static void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .HasKey(p => new { p.InvoiceId, p.DeliveryId }); + builder.Entity() + .HasOne(o => o.Invoice) + .WithOne().OnDelete(DeleteBehavior.Cascade); + builder.Entity() + .HasOne(o => o.Delivery) + .WithOne().OnDelete(DeleteBehavior.Cascade); + } + } +} diff --git a/BTCPayServer.Data/Data/StoreWebhookData.cs b/BTCPayServer.Data/Data/StoreWebhookData.cs new file mode 100644 index 000000000..02ebbe2af --- /dev/null +++ b/BTCPayServer.Data/Data/StoreWebhookData.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Microsoft.EntityFrameworkCore; +using System.Linq; + +namespace BTCPayServer.Data +{ + public class StoreWebhookData + { + public string StoreId { get; set; } + public string WebhookId { get; set; } + public WebhookData Webhook { get; set; } + public StoreData Store { get; set; } + + internal static void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .HasKey(p => new { p.StoreId, p.WebhookId }); + + builder.Entity() + .HasOne(o => o.Webhook) + .WithOne().OnDelete(DeleteBehavior.Cascade); + + builder.Entity() + .HasOne(o => o.Store) + .WithOne().OnDelete(DeleteBehavior.Cascade); + } + } +} diff --git a/BTCPayServer.Data/Data/WebhookData.cs b/BTCPayServer.Data/Data/WebhookData.cs new file mode 100644 index 000000000..ca5b7fd36 --- /dev/null +++ b/BTCPayServer.Data/Data/WebhookData.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Text; +using Microsoft.EntityFrameworkCore; + +namespace BTCPayServer.Data +{ + public class WebhookData + { + [Key] + [MaxLength(25)] + public string Id + { + get; + set; + } + [Required] + public byte[] Blob { get; set; } + public List Deliveries { get; set; } + } +} diff --git a/BTCPayServer.Data/Data/WebhookDeliveryData.cs b/BTCPayServer.Data/Data/WebhookDeliveryData.cs new file mode 100644 index 000000000..3d74c3763 --- /dev/null +++ b/BTCPayServer.Data/Data/WebhookDeliveryData.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text; +using Microsoft.EntityFrameworkCore; + +namespace BTCPayServer.Data +{ + public class WebhookDeliveryData + { + [Key] + [MaxLength(25)] + public string Id { get; set; } + [MaxLength(25)] + [Required] + public string WebhookId { get; set; } + public WebhookData Webhook { get; set; } + + [Required] + public DateTimeOffset Timestamp + { + get; set; + } + + [Required] + public byte[] Blob { get; set; } + internal static void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .HasOne(o => o.Webhook) + .WithMany(a => a.Deliveries).OnDelete(DeleteBehavior.Cascade); + builder.Entity().HasIndex(o => o.WebhookId); + } + } +} diff --git a/BTCPayServer.Data/Migrations/20201108054749_webhooks.cs b/BTCPayServer.Data/Migrations/20201108054749_webhooks.cs new file mode 100644 index 000000000..fcf1d6739 --- /dev/null +++ b/BTCPayServer.Data/Migrations/20201108054749_webhooks.cs @@ -0,0 +1,115 @@ +using System; +using BTCPayServer.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace BTCPayServer.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20201108054749_webhooks")] + public partial class webhooks : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Webhooks", + columns: table => new + { + Id = table.Column(maxLength: 25, nullable: false), + Blob = table.Column(nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Webhooks", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "StoreWebhooks", + columns: table => new + { + StoreId = table.Column(nullable: false), + WebhookId = table.Column(nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_StoreWebhooks", x => new { x.StoreId, x.WebhookId }); + table.ForeignKey( + name: "FK_StoreWebhooks_Stores_StoreId", + column: x => x.StoreId, + principalTable: "Stores", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_StoreWebhooks_Webhooks_WebhookId", + column: x => x.WebhookId, + principalTable: "Webhooks", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "WebhookDeliveries", + columns: table => new + { + Id = table.Column(maxLength: 25, nullable: false), + WebhookId = table.Column(maxLength: 25, nullable: false), + Timestamp = table.Column(nullable: false), + Blob = table.Column(nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_WebhookDeliveries", x => x.Id); + table.ForeignKey( + name: "FK_WebhookDeliveries_Webhooks_WebhookId", + column: x => x.WebhookId, + principalTable: "Webhooks", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "InvoiceWebhookDeliveries", + columns: table => new + { + InvoiceId = table.Column(nullable: false), + DeliveryId = table.Column(nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_InvoiceWebhookDeliveries", x => new { x.InvoiceId, x.DeliveryId }); + table.ForeignKey( + name: "FK_InvoiceWebhookDeliveries_WebhookDeliveries_DeliveryId", + column: x => x.DeliveryId, + principalTable: "WebhookDeliveries", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_InvoiceWebhookDeliveries_Invoices_InvoiceId", + column: x => x.InvoiceId, + principalTable: "Invoices", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_WebhookDeliveries_WebhookId", + table: "WebhookDeliveries", + column: "WebhookId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "InvoiceWebhookDeliveries"); + + migrationBuilder.DropTable( + name: "StoreWebhooks"); + + migrationBuilder.DropTable( + name: "WebhookDeliveries"); + + migrationBuilder.DropTable( + name: "Webhooks"); + } + } +} diff --git a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs index db897fb04..ff64ecef3 100644 --- a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -257,6 +257,25 @@ namespace BTCPayServer.Migrations b.ToTable("InvoiceEvents"); }); + modelBuilder.Entity("BTCPayServer.Data.InvoiceWebhookDeliveryData", b => + { + b.Property("InvoiceId") + .HasColumnType("TEXT"); + + b.Property("DeliveryId") + .HasColumnType("TEXT"); + + b.HasKey("InvoiceId", "DeliveryId"); + + b.HasIndex("DeliveryId") + .IsUnique(); + + b.HasIndex("InvoiceId") + .IsUnique(); + + b.ToTable("InvoiceWebhookDeliveries"); + }); + modelBuilder.Entity("BTCPayServer.Data.NotificationData", b => { b.Property("Id") @@ -588,6 +607,25 @@ namespace BTCPayServer.Migrations b.ToTable("Stores"); }); + modelBuilder.Entity("BTCPayServer.Data.StoreWebhookData", b => + { + b.Property("StoreId") + .HasColumnType("TEXT"); + + b.Property("WebhookId") + .HasColumnType("TEXT"); + + b.HasKey("StoreId", "WebhookId"); + + b.HasIndex("StoreId") + .IsUnique(); + + b.HasIndex("WebhookId") + .IsUnique(); + + b.ToTable("StoreWebhooks"); + }); + modelBuilder.Entity("BTCPayServer.Data.StoredFile", b => { b.Property("Id") @@ -696,6 +734,46 @@ namespace BTCPayServer.Migrations b.ToTable("WalletTransactions"); }); + modelBuilder.Entity("BTCPayServer.Data.WebhookData", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasMaxLength(25); + + b.Property("Blob") + .IsRequired() + .HasColumnType("BLOB"); + + b.HasKey("Id"); + + b.ToTable("Webhooks"); + }); + + modelBuilder.Entity("BTCPayServer.Data.WebhookDeliveryData", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasMaxLength(25); + + b.Property("Blob") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("WebhookId") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(25); + + b.HasKey("Id"); + + b.HasIndex("WebhookId"); + + b.ToTable("WebhookDeliveries"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => { b.Property("Id") @@ -883,6 +961,21 @@ namespace BTCPayServer.Migrations .IsRequired(); }); + modelBuilder.Entity("BTCPayServer.Data.InvoiceWebhookDeliveryData", b => + { + b.HasOne("BTCPayServer.Data.WebhookDeliveryData", "Delivery") + .WithOne() + .HasForeignKey("BTCPayServer.Data.InvoiceWebhookDeliveryData", "DeliveryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BTCPayServer.Data.InvoiceData", "Invoice") + .WithOne() + .HasForeignKey("BTCPayServer.Data.InvoiceWebhookDeliveryData", "InvoiceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("BTCPayServer.Data.NotificationData", b => { b.HasOne("BTCPayServer.Data.ApplicationUser", "ApplicationUser") @@ -956,6 +1049,21 @@ namespace BTCPayServer.Migrations .IsRequired(); }); + modelBuilder.Entity("BTCPayServer.Data.StoreWebhookData", b => + { + b.HasOne("BTCPayServer.Data.StoreData", "Store") + .WithOne() + .HasForeignKey("BTCPayServer.Data.StoreWebhookData", "StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BTCPayServer.Data.WebhookData", "Webhook") + .WithOne() + .HasForeignKey("BTCPayServer.Data.StoreWebhookData", "WebhookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("BTCPayServer.Data.StoredFile", b => { b.HasOne("BTCPayServer.Data.ApplicationUser", "ApplicationUser") @@ -995,6 +1103,15 @@ namespace BTCPayServer.Migrations .IsRequired(); }); + modelBuilder.Entity("BTCPayServer.Data.WebhookDeliveryData", b => + { + b.HasOne("BTCPayServer.Data.WebhookData", "Webhook") + .WithMany("Deliveries") + .HasForeignKey("WebhookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) diff --git a/BTCPayServer.Tests/BTCPayServer.Tests.csproj b/BTCPayServer.Tests/BTCPayServer.Tests.csproj index d6ab7faa4..ecf82d0fb 100644 --- a/BTCPayServer.Tests/BTCPayServer.Tests.csproj +++ b/BTCPayServer.Tests/BTCPayServer.Tests.csproj @@ -21,6 +21,7 @@ + diff --git a/BTCPayServer.Tests/RawHttpServer.cs b/BTCPayServer.Tests/RawHttpServer.cs new file mode 100644 index 000000000..abc2a6f4a --- /dev/null +++ b/BTCPayServer.Tests/RawHttpServer.cs @@ -0,0 +1,89 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace BTCPayServer.Tests +{ + public class RawHttpServer : IDisposable + { + public class RawRequest + { + public RawRequest(TaskCompletionSource taskCompletion) + { + TaskCompletion = taskCompletion; + } + public HttpContext HttpContext { get; set; } + public TaskCompletionSource TaskCompletion { get; } + + public void Complete() + { + TaskCompletion.SetResult(true); + } + } + readonly IWebHost _Host = null; + readonly CancellationTokenSource _Closed = new CancellationTokenSource(); + readonly Channel _Requests = Channel.CreateUnbounded(); + public RawHttpServer() + { + var port = Utils.FreeTcpPort(); + _Host = new WebHostBuilder() + .Configure(app => + { + app.Run(req => + { + var cts = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _Requests.Writer.TryWrite(new RawRequest(cts) + { + HttpContext = req + }); + return cts.Task; + }); + }) + .UseKestrel() + .UseUrls("http://127.0.0.1:" + port) + .Build(); + _Host.Start(); + } + + public Uri GetUri() + { + return new Uri(_Host.ServerFeatures.Get().Addresses.First()); + } + + public async Task GetNextRequest() + { + using (CancellationTokenSource cancellation = new CancellationTokenSource(20 * 1000)) + { + try + { + RawRequest req = null; + while (!await _Requests.Reader.WaitToReadAsync(cancellation.Token) || + !_Requests.Reader.TryRead(out req)) + { + + } + return req; + } + catch (TaskCanceledException) + { + throw new Xunit.Sdk.XunitException("Callback to the webserver was expected, check if the callback url is accessible from internet"); + } + } + } + + public void Dispose() + { + _Closed.Cancel(); + _Host.Dispose(); + } + } +} diff --git a/BTCPayServer.Tests/SeleniumTester.cs b/BTCPayServer.Tests/SeleniumTester.cs index 12dd261ba..69a943834 100644 --- a/BTCPayServer.Tests/SeleniumTester.cs +++ b/BTCPayServer.Tests/SeleniumTester.cs @@ -317,10 +317,9 @@ namespace BTCPayServer.Tests Driver.FindElement(By.Name("StoreId")).SendKeys(storeName); Driver.FindElement(By.Id("Create")).Click(); - Assert.True(Driver.PageSource.Contains("just created!"), "Unable to create Invoice"); + AssertHappyMessage(); var statusElement = Driver.FindElement(By.ClassName("alert-success")); var id = statusElement.Text.Split(" ")[1]; - return id; } diff --git a/BTCPayServer.Tests/SeleniumTests.cs b/BTCPayServer.Tests/SeleniumTests.cs index 5cf19d7e4..9e1a65bc4 100644 --- a/BTCPayServer.Tests/SeleniumTests.cs +++ b/BTCPayServer.Tests/SeleniumTests.cs @@ -1,9 +1,11 @@ using System; using System.Globalization; using System.Linq; +using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using BTCPayServer.Client.Models; using BTCPayServer.Data; using BTCPayServer.Models; using BTCPayServer.Services.Wallets; @@ -12,11 +14,18 @@ using BTCPayServer.Views.Server; using BTCPayServer.Views.Wallets; using Microsoft.EntityFrameworkCore; using NBitcoin; +using NBitcoin.DataEncoders; using NBitcoin.Payment; using NBitpayClient; +using Newtonsoft.Json.Linq; using OpenQA.Selenium; +using OpenQA.Selenium.Support.Extensions; +using OpenQA.Selenium.Support.UI; +using Org.BouncyCastle.Ocsp; +using Renci.SshNet.Security.Cryptography; using Xunit; using Xunit.Abstractions; +using Xunit.Sdk; namespace BTCPayServer.Tests { @@ -133,19 +142,20 @@ namespace BTCPayServer.Tests //let's test invite link s.Logout(); s.GoToRegister(); - var newAdminUser = s.RegisterNewUser(true); + var newAdminUser = s.RegisterNewUser(true); s.GoToServer(ServerNavPages.Users); s.Driver.FindElement(By.Id("CreateUser")).Click(); - + var usr = RandomUtils.GetUInt256().ToString().Substring(64 - 20) + "@a.com"; s.Driver.FindElement(By.Id("Email")).SendKeys(usr); s.Driver.FindElement(By.Id("Save")).Click(); - var url = s.AssertHappyMessage().FindElement(By.TagName("a")).Text;; + var url = s.AssertHappyMessage().FindElement(By.TagName("a")).Text; + ; s.Logout(); s.Driver.Navigate().GoToUrl(url); - Assert.Equal("hidden",s.Driver.FindElement(By.Id("Email")).GetAttribute("type")); - Assert.Equal(usr,s.Driver.FindElement(By.Id("Email")).GetAttribute("value")); - + Assert.Equal("hidden", s.Driver.FindElement(By.Id("Email")).GetAttribute("type")); + Assert.Equal(usr, s.Driver.FindElement(By.Id("Email")).GetAttribute("value")); + s.Driver.FindElement(By.Id("Password")).SendKeys("123456"); s.Driver.FindElement(By.Id("ConfirmPassword")).SendKeys("123456"); s.Driver.FindElement(By.Id("SetPassword")).Click(); @@ -595,6 +605,132 @@ namespace BTCPayServer.Tests } } + [Fact(Timeout = TestTimeout)] + public async Task CanUseWebhooks() + { + using (var s = SeleniumTester.Create()) + { + await s.StartAsync(); + s.RegisterNewUser(true); + var store = s.CreateNewStore(); + s.GoToStore(store.storeId, Views.Stores.StoreNavPages.Webhooks); + + Logs.Tester.LogInformation("Let's create two webhooks"); + for (int i = 0; i < 2; i++) + { + s.Driver.FindElement(By.Id("CreateWebhook")).Click(); + s.Driver.FindElement(By.Name("PayloadUrl")).SendKeys($"http://127.0.0.1/callback{i}"); + new SelectElement(s.Driver.FindElement(By.Name("Everything"))) + .SelectByValue("false"); + s.Driver.FindElement(By.Id("InvoiceCreated")).Click(); + s.Driver.FindElement(By.Id("InvoicePaidInFull")).Click(); + s.Driver.FindElement(By.Name("add")).Click(); + } + + Logs.Tester.LogInformation("Let's delete one of them"); + var deletes = s.Driver.FindElements(By.LinkText("Delete")); + Assert.Equal(2, deletes.Count); + deletes[0].Click(); + s.Driver.FindElement(By.Id("continue")).Click(); + deletes = s.Driver.FindElements(By.LinkText("Delete")); + Assert.Single(deletes); + s.AssertHappyMessage(); + + Logs.Tester.LogInformation("Let's try to update one of them"); + s.Driver.FindElement(By.LinkText("Modify")).Click(); + + using RawHttpServer server = new RawHttpServer(); + + s.Driver.FindElement(By.Name("PayloadUrl")).Clear(); + s.Driver.FindElement(By.Name("PayloadUrl")).SendKeys(server.GetUri().AbsoluteUri); + s.Driver.FindElement(By.Name("Secret")).Clear(); + s.Driver.FindElement(By.Name("Secret")).SendKeys("HelloWorld"); + s.Driver.FindElement(By.Name("update")).Click(); + s.AssertHappyMessage(); + s.Driver.FindElement(By.LinkText("Modify")).Click(); + foreach (var value in Enum.GetValues(typeof(WebhookEventType))) + { + // Here we make sure we did not forget an event type in the list + // However, maybe some event should not appear here because not at the store level. + // Fix as needed. + Assert.Contains($"value=\"{value}\"", s.Driver.PageSource); + } + // This one should be checked + Assert.Contains($"value=\"InvoicePaidInFull\" checked", s.Driver.PageSource); + Assert.Contains($"value=\"InvoiceCreated\" checked", s.Driver.PageSource); + // This one never been checked + Assert.DoesNotContain($"value=\"InvoiceReceivedPayment\" checked", s.Driver.PageSource); + + s.Driver.FindElement(By.Name("update")).Click(); + s.AssertHappyMessage(); + Assert.Contains(server.GetUri().AbsoluteUri, s.Driver.PageSource); + + Logs.Tester.LogInformation("Let's see if we can generate an event"); + s.GoToStore(store.storeId); + s.AddDerivationScheme(); + s.CreateInvoice(store.storeName); + var request = await server.GetNextRequest(); + var headers = request.HttpContext.Request.Headers; + var actualSig = headers["BTCPay-Sig"].First(); + var bytes = await request.HttpContext.Request.Body.ReadBytesAsync((int)headers.ContentLength.Value); + var expectedSig = $"sha256={Encoders.Hex.EncodeData(new HMACSHA256(Encoding.UTF8.GetBytes("HelloWorld")).ComputeHash(bytes))}"; + Assert.Equal(expectedSig, actualSig); + request.HttpContext.Response.StatusCode = 200; + request.Complete(); + + Logs.Tester.LogInformation("Let's make a failed event"); + s.CreateInvoice(store.storeName); + request = await server.GetNextRequest(); + request.HttpContext.Response.StatusCode = 404; + request.Complete(); + + // The delivery is done asynchronously, so small wait here + await Task.Delay(500); + s.GoToStore(store.storeId, Views.Stores.StoreNavPages.Webhooks); + s.Driver.FindElement(By.LinkText("Modify")).Click(); + var elements = s.Driver.FindElements(By.ClassName("redeliver")); + // One worked, one failed + s.Driver.FindElement(By.ClassName("fa-times")); + s.Driver.FindElement(By.ClassName("fa-check")); + elements[0].Click(); + s.AssertHappyMessage(); + request = await server.GetNextRequest(); + request.HttpContext.Response.StatusCode = 404; + request.Complete(); + + Logs.Tester.LogInformation("Can we browse the json content?"); + CanBrowseContent(s); + + s.GoToInvoices(); + s.Driver.FindElement(By.LinkText("Details")).Click(); + CanBrowseContent(s); + var element = s.Driver.FindElement(By.ClassName("redeliver")); + element.Click(); + s.AssertHappyMessage(); + request = await server.GetNextRequest(); + request.HttpContext.Response.StatusCode = 404; + request.Complete(); + + Logs.Tester.LogInformation("Let's see if we can delete store with some webhooks inside"); + s.GoToStore(store.storeId); + s.Driver.ExecuteJavaScript("window.scrollBy(0,1000);"); + s.Driver.FindElement(By.Id("danger-zone-expander")).Click(); + s.Driver.FindElement(By.Id("delete-store")).Click(); + s.Driver.FindElement(By.Id("continue")).Click(); + s.AssertHappyMessage(); + } + } + + private static void CanBrowseContent(SeleniumTester s) + { + s.Driver.FindElement(By.ClassName("delivery-content")).Click(); + var windows = s.Driver.WindowHandles; + Assert.Equal(2, windows.Count); + s.Driver.SwitchTo().Window(windows[1]); + JObject.Parse(s.Driver.FindElement(By.TagName("body")).Text); + s.Driver.Close(); + s.Driver.SwitchTo().Window(windows[0]); + } [Fact(Timeout = TestTimeout)] public async Task CanManageWallet() diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index ad2f59a1c..fe51e7325 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -999,7 +999,6 @@ namespace BTCPayServer.Tests } } } - var invoice2 = acc.BitPay.GetInvoice(invoice.Id); Assert.NotNull(invoice2); } diff --git a/BTCPayServer/Controllers/InvoiceController.UI.cs b/BTCPayServer/Controllers/InvoiceController.UI.cs index 6548f6cc8..f739c1f9b 100644 --- a/BTCPayServer/Controllers/InvoiceController.UI.cs +++ b/BTCPayServer/Controllers/InvoiceController.UI.cs @@ -39,6 +39,51 @@ namespace BTCPayServer.Controllers { public partial class InvoiceController { + + [HttpGet] + [Route("invoices/{invoiceId}/deliveries/{deliveryId}/request")] + [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)] + public async Task WebhookDelivery(string invoiceId, string deliveryId) + { + var invoice = (await _InvoiceRepository.GetInvoices(new InvoiceQuery() + { + InvoiceId = new[] { invoiceId }, + UserId = GetUserId() + })).FirstOrDefault(); + if (invoice is null) + return NotFound(); + var delivery = await _InvoiceRepository.GetWebhookDelivery(invoiceId, deliveryId); + if (delivery is null) + return NotFound(); + return this.File(delivery.GetBlob().Request, "application/json"); + } + [HttpPost] + [Route("invoices/{invoiceId}/deliveries/{deliveryId}/redeliver")] + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] + public async Task RedeliverWebhook(string storeId, string invoiceId, string deliveryId) + { + var invoice = (await _InvoiceRepository.GetInvoices(new InvoiceQuery() + { + InvoiceId = new[] { invoiceId }, + StoreId = new[] { storeId }, + UserId = GetUserId() + })).FirstOrDefault(); + if (invoice is null) + return NotFound(); + var delivery = await _InvoiceRepository.GetWebhookDelivery(invoiceId, deliveryId); + if (delivery is null) + return NotFound(); + var newDeliveryId = await WebhookNotificationManager.Redeliver(deliveryId); + if (newDeliveryId is null) + return NotFound(); + TempData[WellKnownTempData.SuccessMessage] = "Successfully planned a redelivery"; + return RedirectToAction(nameof(Invoice), + new + { + invoiceId + }); + } + [HttpGet] [Route("invoices/{invoiceId}")] [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)] @@ -58,6 +103,7 @@ namespace BTCPayServer.Controllers var store = await _StoreRepository.FindStore(invoice.StoreId); var model = new InvoiceDetailsModel() { + StoreId = store.Id, StoreName = store.StoreName, StoreLink = Url.Action(nameof(StoresController.UpdateStore), "Stores", new { storeId = store.Id }), Id = invoice.Id, @@ -80,6 +126,9 @@ namespace BTCPayServer.Controllers PosData = PosDataParser.ParsePosData(invoice.Metadata.PosData), Archived = invoice.Archived, CanRefund = CanRefund(invoice.GetInvoiceState()), + Deliveries = (await _InvoiceRepository.GetWebhookDeliveries(invoiceId)) + .Select(c => new Models.StoreViewModels.DeliveryViewModel(c)) + .ToList() }; model.Addresses = invoice.HistoricalAddresses.Select(h => new InvoiceDetailsModel.AddressModel diff --git a/BTCPayServer/Controllers/InvoiceController.cs b/BTCPayServer/Controllers/InvoiceController.cs index e5b2f2ccc..aac64c62e 100644 --- a/BTCPayServer/Controllers/InvoiceController.cs +++ b/BTCPayServer/Controllers/InvoiceController.cs @@ -45,6 +45,9 @@ namespace BTCPayServer.Controllers private readonly ApplicationDbContextFactory _dbContextFactory; private readonly PullPaymentHostedService _paymentHostedService; readonly IServiceProvider _ServiceProvider; + + public WebhookNotificationManager WebhookNotificationManager { get; } + public InvoiceController( IServiceProvider serviceProvider, InvoiceRepository invoiceRepository, @@ -57,7 +60,8 @@ namespace BTCPayServer.Controllers BTCPayNetworkProvider networkProvider, PaymentMethodHandlerDictionary paymentMethodHandlerDictionary, ApplicationDbContextFactory dbContextFactory, - PullPaymentHostedService paymentHostedService) + PullPaymentHostedService paymentHostedService, + WebhookNotificationManager webhookNotificationManager) { _ServiceProvider = serviceProvider; _CurrencyNameTable = currencyNameTable ?? throw new ArgumentNullException(nameof(currencyNameTable)); @@ -70,6 +74,7 @@ namespace BTCPayServer.Controllers _paymentMethodHandlerDictionary = paymentMethodHandlerDictionary; _dbContextFactory = dbContextFactory; _paymentHostedService = paymentHostedService; + WebhookNotificationManager = webhookNotificationManager; _CSP = csp; } diff --git a/BTCPayServer/Controllers/StoresController.Integrations.cs b/BTCPayServer/Controllers/StoresController.Integrations.cs index 6230d549b..503a7f43b 100644 --- a/BTCPayServer/Controllers/StoresController.Integrations.cs +++ b/BTCPayServer/Controllers/StoresController.Integrations.cs @@ -1,12 +1,16 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Globalization; using System.IO; using System.Linq; using System.Net.Http; +using System.Text; using System.Threading.Tasks; +using BTCPayServer.Client; using BTCPayServer.Client.Models; using BTCPayServer.Data; +using BTCPayServer.Models; using BTCPayServer.Models.StoreViewModels; using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Shopify; @@ -16,6 +20,10 @@ using BTCPayServer.Services.Stores; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NBitcoin; +using NBitcoin.DataEncoders; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; using NicolasDorier.RateLimits; @@ -171,6 +179,127 @@ namespace BTCPayServer.Controllers return View("Integrations", vm); } + [HttpGet] + [Route("{storeId}/webhooks")] + public async Task Webhooks() + { + var webhooks = await this._Repo.GetWebhooks(CurrentStore.Id); + return View(nameof(Webhooks), new WebhooksViewModel() + { + Webhooks = webhooks.Select(w => new WebhooksViewModel.WebhookViewModel() + { + Id = w.Id, + Url = w.GetBlob().Url + }).ToArray() + }); + } + [HttpGet] + [Route("{storeId}/webhooks/new")] + public IActionResult NewWebhook() + { + return View(nameof(ModifyWebhook), new EditWebhookViewModel() + { + Active = true, + Everything = true, + IsNew = true, + Secret = Encoders.Base58.EncodeData(RandomUtils.GetBytes(20)) + }); + } + + [HttpGet] + [Route("{storeId}/webhooks/{webhookId}/remove")] + public async Task DeleteWebhook(string webhookId) + { + var webhook = await _Repo.GetWebhook(CurrentStore.Id, webhookId); + if (webhook is null) + return NotFound(); + return View("Confirm", new ConfirmModel() + { + Title = $"Delete a webhook", + Description = "This webhook will be removed from this store, do you wish to continue?", + Action = "Delete" + }); + } + + [HttpPost] + [Route("{storeId}/webhooks/{webhookId}/remove")] + public async Task DeleteWebhookPost(string webhookId) + { + var webhook = await _Repo.GetWebhook(CurrentStore.Id, webhookId); + if (webhook is null) + return NotFound(); + await _Repo.DeleteWebhook(CurrentStore.Id, webhookId); + TempData[WellKnownTempData.SuccessMessage] = "Webhook successfully deleted"; + return RedirectToAction(nameof(Webhooks), new { storeId = CurrentStore.Id }); + } + + [HttpPost] + [Route("{storeId}/webhooks/new")] + public async Task NewWebhook(string storeId, EditWebhookViewModel viewModel) + { + if (!ModelState.IsValid) + return View(viewModel); + + var webhookId = await _Repo.CreateWebhook(CurrentStore.Id, viewModel.CreateBlob()); + TempData[WellKnownTempData.SuccessMessage] = "The webhook has been created"; + return RedirectToAction(nameof(Webhooks), new { storeId }); + } + [HttpGet] + [Route("{storeId}/webhooks/{webhookId}")] + public async Task ModifyWebhook(string webhookId) + { + var webhook = await _Repo.GetWebhook(CurrentStore.Id, webhookId); + if (webhook is null) + return NotFound(); + var blob = webhook.GetBlob(); + var deliveries = await _Repo.GetWebhookDeliveries(CurrentStore.Id, webhookId, 20); + return View(nameof(ModifyWebhook), new EditWebhookViewModel(blob) + { + Deliveries = deliveries + .Select(s => new DeliveryViewModel(s)).ToList() + }); + } + [HttpPost] + [Route("{storeId}/webhooks/{webhookId}")] + public async Task ModifyWebhook(string webhookId, EditWebhookViewModel viewModel) + { + var webhook = await _Repo.GetWebhook(CurrentStore.Id, webhookId); + if (webhook is null) + return NotFound(); + + await _Repo.UpdateWebhook(CurrentStore.Id, webhookId, viewModel.CreateBlob()); + TempData[WellKnownTempData.SuccessMessage] = "The webhook has been updated"; + return RedirectToAction(nameof(Webhooks), new { storeId = CurrentStore.Id }); + } + + [HttpPost] + [Route("{storeId}/webhooks/{webhookId}/deliveries/{deliveryId}/redeliver")] + public async Task RedeliverWebhook(string webhookId, string deliveryId) + { + var delivery = await _Repo.GetWebhookDelivery(CurrentStore.Id, webhookId, deliveryId); + if (delivery is null) + return NotFound(); + var newDeliveryId = await WebhookNotificationManager.Redeliver(deliveryId); + if (newDeliveryId is null) + return NotFound(); + TempData[WellKnownTempData.SuccessMessage] = "Successfully planned a redelivery"; + return RedirectToAction(nameof(ModifyWebhook), + new + { + storeId = CurrentStore.Id, + webhookId + }); + } + [HttpGet] + [Route("{storeId}/webhooks/{webhookId}/deliveries/{deliveryId}/request")] + public async Task WebhookDelivery(string webhookId, string deliveryId) + { + var delivery = await _Repo.GetWebhookDelivery(CurrentStore.Id, webhookId, deliveryId); + if (delivery is null) + return NotFound(); + return this.File(delivery.GetBlob().Request, "application/json"); + } + [HttpPost] [Route("{storeId}/integrations/shopify")] public async Task Integrations([FromServices] IHttpClientFactory clientFactory, diff --git a/BTCPayServer/Controllers/StoresController.cs b/BTCPayServer/Controllers/StoresController.cs index 0d7153532..f7ebfc642 100644 --- a/BTCPayServer/Controllers/StoresController.cs +++ b/BTCPayServer/Controllers/StoresController.cs @@ -63,7 +63,8 @@ namespace BTCPayServer.Controllers EventAggregator eventAggregator, CssThemeManager cssThemeManager, AppService appService, - IWebHostEnvironment webHostEnvironment) + IWebHostEnvironment webHostEnvironment, + WebhookNotificationManager webhookNotificationManager) { _RateFactory = rateFactory; _Repo = repo; @@ -78,6 +79,7 @@ namespace BTCPayServer.Controllers _CssThemeManager = cssThemeManager; _appService = appService; _webHostEnvironment = webHostEnvironment; + WebhookNotificationManager = webhookNotificationManager; _EventAggregator = eventAggregator; _NetworkProvider = networkProvider; _ExplorerProvider = explorerProvider; @@ -784,6 +786,7 @@ namespace BTCPayServer.Controllers } public string GeneratedPairingCode { get; set; } + public WebhookNotificationManager WebhookNotificationManager { get; } [HttpGet] [Route("{storeId}/Tokens/Create")] diff --git a/BTCPayServer/Data/WebhookDataExtensions.cs b/BTCPayServer/Data/WebhookDataExtensions.cs new file mode 100644 index 000000000..53de1037a --- /dev/null +++ b/BTCPayServer/Data/WebhookDataExtensions.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using BTCPayServer.Client.Models; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using SshNet.Security.Cryptography; + +namespace BTCPayServer.Data +{ + public class AuthorizedWebhookEvents + { + public bool Everything { get; set; } + + [JsonProperty(ItemConverterType = typeof(Newtonsoft.Json.Converters.StringEnumConverter))] + public WebhookEventType[] SpecificEvents { get; set; } = Array.Empty(); + public bool Match(WebhookEventType evt) + { + return Everything || SpecificEvents.Contains(evt); + } + } + + + public class WebhookDeliveryBlob + { + [JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))] + public WebhookDeliveryStatus Status { get; set; } + public int? HttpCode { get; set; } + public string ErrorMessage { get; set; } + public byte[] Request { get; set; } + public T ReadRequestAs() + { + return JsonConvert.DeserializeObject(UTF8Encoding.UTF8.GetString(Request), HostedServices.WebhookNotificationManager.DefaultSerializerSettings); + } + } + public class WebhookBlob + { + public string Url { get; set; } + public bool Active { get; set; } = true; + public string Secret { get; set; } + public bool AutomaticRedelivery { get; set; } + public AuthorizedWebhookEvents AuthorizedEvents { get; set; } + } + public static class WebhookDataExtensions + { + public static WebhookBlob GetBlob(this WebhookData webhook) + { + return JsonConvert.DeserializeObject(Encoding.UTF8.GetString(webhook.Blob)); + } + public static void SetBlob(this WebhookData webhook, WebhookBlob blob) + { + webhook.Blob = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(blob)); + } + public static WebhookDeliveryBlob GetBlob(this WebhookDeliveryData webhook) + { + return JsonConvert.DeserializeObject(ZipUtils.Unzip(webhook.Blob), HostedServices.WebhookNotificationManager.DefaultSerializerSettings); + } + public static void SetBlob(this WebhookDeliveryData webhook, WebhookDeliveryBlob blob) + { + webhook.Blob = ZipUtils.Zip(JsonConvert.SerializeObject(blob, Formatting.None, HostedServices.WebhookNotificationManager.DefaultSerializerSettings)); + } + } +} diff --git a/BTCPayServer/HostedServices/EventHostedServiceBase.cs b/BTCPayServer/HostedServices/EventHostedServiceBase.cs index e22bc01b9..b1de13f80 100644 --- a/BTCPayServer/HostedServices/EventHostedServiceBase.cs +++ b/BTCPayServer/HostedServices/EventHostedServiceBase.cs @@ -16,7 +16,7 @@ namespace BTCPayServer.HostedServices private List _Subscriptions; private CancellationTokenSource _Cts; - + public CancellationToken CancellationToken => _Cts.Token; public EventHostedServiceBase(EventAggregator eventAggregator) { _EventAggregator = eventAggregator; @@ -61,6 +61,11 @@ namespace BTCPayServer.HostedServices _Subscriptions.Add(_EventAggregator.Subscribe(e => _Events.Writer.TryWrite(e))); } + protected void PushEvent(object obj) + { + _Events.Writer.TryWrite(obj); + } + public virtual Task StartAsync(CancellationToken cancellationToken) { _Subscriptions = new List(); diff --git a/BTCPayServer/HostedServices/WebhookNotificationManager.cs b/BTCPayServer/HostedServices/WebhookNotificationManager.cs new file mode 100644 index 000000000..1f2eaf067 --- /dev/null +++ b/BTCPayServer/HostedServices/WebhookNotificationManager.cs @@ -0,0 +1,304 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.NetworkInformation; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Amazon.Runtime.Internal; +using Amazon.S3.Model; +using BTCPayServer.Client.Models; +using BTCPayServer.Data; +using BTCPayServer.Events; +using BTCPayServer.Logging; +using BTCPayServer.Services.Stores; +using Microsoft.CodeAnalysis.Operations; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using NBitcoin; +using NBitcoin.DataEncoders; +using NBitcoin.Secp256k1; +using NBitpayClient; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Serialization; +using Org.BouncyCastle.Ocsp; +using TwentyTwenty.Storage; + +namespace BTCPayServer.HostedServices +{ + /// + /// This class send webhook notifications + /// It also make sure the events sent to a webhook are sent in order to the webhook + /// + public class WebhookNotificationManager : EventHostedServiceBase + { + readonly Encoding UTF8 = new UTF8Encoding(false); + public readonly static JsonSerializerSettings DefaultSerializerSettings; + static WebhookNotificationManager() + { + DefaultSerializerSettings = new JsonSerializerSettings(); + DefaultSerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver(); + NBitcoin.JsonConverters.Serializer.RegisterFrontConverters(DefaultSerializerSettings); + DefaultSerializerSettings.Formatting = Formatting.None; + } + public const string OnionNamedClient = "greenfield-webhook.onion"; + public const string ClearnetNamedClient = "greenfield-webhook.clearnet"; + private HttpClient GetClient(Uri uri) + { + return HttpClientFactory.CreateClient(uri.IsOnion() ? OnionNamedClient : ClearnetNamedClient); + } + class WebhookDeliveryRequest + { + public WebhookEvent WebhookEvent; + public WebhookDeliveryData Delivery; + public WebhookBlob WebhookBlob; + public string WebhookId; + public WebhookDeliveryRequest(string webhookId, WebhookEvent webhookEvent, WebhookDeliveryData delivery, WebhookBlob webhookBlob) + { + WebhookId = webhookId; + WebhookEvent = webhookEvent; + Delivery = delivery; + WebhookBlob = webhookBlob; + } + } + Dictionary> _InvoiceEventsByWebhookId = new Dictionary>(); + public StoreRepository StoreRepository { get; } + public IHttpClientFactory HttpClientFactory { get; } + + public WebhookNotificationManager(EventAggregator eventAggregator, + StoreRepository storeRepository, + IHttpClientFactory httpClientFactory) : base(eventAggregator) + { + StoreRepository = storeRepository; + HttpClientFactory = httpClientFactory; + } + + protected override void SubscribeToEvents() + { + Subscribe(); + } + + public async Task Redeliver(string deliveryId) + { + var deliveryRequest = await CreateRedeliveryRequest(deliveryId); + EnqueueDelivery(deliveryRequest); + return deliveryRequest.Delivery.Id; + } + + private async Task CreateRedeliveryRequest(string deliveryId) + { + using var ctx = StoreRepository.CreateDbContext(); + var webhookDelivery = await ctx.WebhookDeliveries.AsNoTracking() + .Where(o => o.Id == deliveryId) + .Select(o => new + { + Webhook = o.Webhook, + Delivery = o + }) + .FirstOrDefaultAsync(); + if (webhookDelivery is null) + return null; + var oldDeliveryBlob = webhookDelivery.Delivery.GetBlob(); + var newDelivery = NewDelivery(); + newDelivery.WebhookId = webhookDelivery.Webhook.Id; + var newDeliveryBlob = new WebhookDeliveryBlob(); + newDeliveryBlob.Request = oldDeliveryBlob.Request; + var webhookEvent = newDeliveryBlob.ReadRequestAs(); + webhookEvent.DeliveryId = newDelivery.Id; + webhookEvent.OrignalDeliveryId ??= deliveryId; + webhookEvent.Timestamp = newDelivery.Timestamp; + newDeliveryBlob.Request = ToBytes(webhookEvent); + newDelivery.SetBlob(newDeliveryBlob); + return new WebhookDeliveryRequest(webhookDelivery.Webhook.Id, webhookEvent, newDelivery, webhookDelivery.Webhook.GetBlob()); + } + protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken) + { + if (evt is InvoiceEvent invoiceEvent) + { + var webhooks = await StoreRepository.GetWebhooks(invoiceEvent.Invoice.StoreId); + foreach (var webhook in webhooks) + { + var webhookBlob = webhook.GetBlob(); + if (!(GetWebhookEvent(invoiceEvent.EventCode) is WebhookEventType webhookEventType)) + continue; + if (!ShouldDeliver(webhookEventType, webhookBlob)) + continue; + WebhookDeliveryData delivery = NewDelivery(); + delivery.WebhookId = webhook.Id; + var webhookEvent = new WebhookInvoiceEvent(); + webhookEvent.InvoiceId = invoiceEvent.InvoiceId; + webhookEvent.StoreId = invoiceEvent.Invoice.StoreId; + webhookEvent.Type = webhookEventType; + webhookEvent.DeliveryId = delivery.Id; + webhookEvent.OrignalDeliveryId = delivery.Id; + webhookEvent.Timestamp = delivery.Timestamp; + var context = new WebhookDeliveryRequest(webhook.Id, webhookEvent, delivery, webhookBlob); + EnqueueDelivery(context); + } + } + } + + private void EnqueueDelivery(WebhookDeliveryRequest context) + { + if (_InvoiceEventsByWebhookId.TryGetValue(context.WebhookId, out var channel)) + { + if (channel.Writer.TryWrite(context)) + return; + } + channel = Channel.CreateUnbounded(); + _InvoiceEventsByWebhookId.Add(context.WebhookId, channel); + channel.Writer.TryWrite(context); + _ = Process(context.WebhookId, channel); + } + + private WebhookEventType? GetWebhookEvent(InvoiceEventCode eventCode) + { + switch (eventCode) + { + case InvoiceEventCode.Completed: + return null; + case InvoiceEventCode.Confirmed: + case InvoiceEventCode.MarkedCompleted: + return WebhookEventType.InvoiceConfirmed; + case InvoiceEventCode.Created: + return WebhookEventType.InvoiceCreated; + case InvoiceEventCode.Expired: + case InvoiceEventCode.ExpiredPaidPartial: + return WebhookEventType.InvoiceExpired; + case InvoiceEventCode.FailedToConfirm: + case InvoiceEventCode.MarkedInvalid: + return WebhookEventType.InvoiceInvalid; + case InvoiceEventCode.PaidInFull: + return WebhookEventType.InvoicePaidInFull; + case InvoiceEventCode.ReceivedPayment: + case InvoiceEventCode.PaidAfterExpiration: + return WebhookEventType.InvoiceReceivedPayment; + default: + return null; + } + } + + private async Task Process(string id, Channel channel) + { + await foreach (var originalCtx in channel.Reader.ReadAllAsync()) + { + try + { + var ctx = originalCtx; + var wh = (await StoreRepository.GetWebhook(ctx.WebhookId)).GetBlob(); + if (!ShouldDeliver(ctx.WebhookEvent.Type, wh)) + continue; + var result = await SendDelivery(ctx); + if (ctx.WebhookBlob.AutomaticRedelivery && + !result.Success && + result.DeliveryId is string) + { + var originalDeliveryId = result.DeliveryId; + foreach (var wait in new[] + { + TimeSpan.FromSeconds(10), + TimeSpan.FromMinutes(1), + TimeSpan.FromMinutes(10), + TimeSpan.FromMinutes(10), + TimeSpan.FromMinutes(10), + TimeSpan.FromMinutes(10), + TimeSpan.FromMinutes(10), + TimeSpan.FromMinutes(10), + }) + { + await Task.Delay(wait, CancellationToken); + ctx = await CreateRedeliveryRequest(originalDeliveryId); + // This may have changed + if (!ctx.WebhookBlob.AutomaticRedelivery || + !ShouldDeliver(ctx.WebhookEvent.Type, ctx.WebhookBlob)) + break; + result = await SendDelivery(ctx); + if (result.Success) + break; + } + } + } + catch when (CancellationToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + Logs.PayServer.LogError(ex, "Unexpected error when processing a webhook"); + } + } + } + + private static bool ShouldDeliver(WebhookEventType type, WebhookBlob wh) + { + return wh.Active && wh.AuthorizedEvents.Match(type); + } + + class DeliveryResult + { + public string DeliveryId { get; set; } + public bool Success { get; set; } + } + private async Task SendDelivery(WebhookDeliveryRequest ctx) + { + var uri = new Uri(ctx.WebhookBlob.Url, UriKind.Absolute); + var httpClient = GetClient(uri); + using var request = new HttpRequestMessage(); + request.RequestUri = uri; + request.Method = HttpMethod.Post; + byte[] bytes = ToBytes(ctx.WebhookEvent); + var content = new ByteArrayContent(bytes); + content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + using var hmac = new System.Security.Cryptography.HMACSHA256(UTF8.GetBytes(ctx.WebhookBlob.Secret ?? string.Empty)); + var sig = Encoders.Hex.EncodeData(hmac.ComputeHash(bytes)); + content.Headers.Add("BTCPay-Sig", $"sha256={sig}"); + request.Content = content; + var deliveryBlob = ctx.Delivery.Blob is null ? new WebhookDeliveryBlob() : ctx.Delivery.GetBlob(); + deliveryBlob.Request = bytes; + try + { + using var response = await httpClient.SendAsync(request, CancellationToken); + if (!response.IsSuccessStatusCode) + { + deliveryBlob.Status = WebhookDeliveryStatus.HttpError; + deliveryBlob.ErrorMessage = $"HTTP Error Code {(int)response.StatusCode}"; + } + else + { + deliveryBlob.Status = WebhookDeliveryStatus.HttpSuccess; + } + deliveryBlob.HttpCode = (int)response.StatusCode; + } + catch (Exception ex) when (!CancellationToken.IsCancellationRequested) + { + deliveryBlob.Status = WebhookDeliveryStatus.Failed; + deliveryBlob.ErrorMessage = ex.Message; + } + ctx.Delivery.SetBlob(deliveryBlob); + await StoreRepository.AddWebhookDelivery(ctx.Delivery); + return new DeliveryResult() { Success = deliveryBlob.ErrorMessage is null, DeliveryId = ctx.Delivery.Id }; + } + + private byte[] ToBytes(WebhookEvent webhookEvent) + { + var str = JsonConvert.SerializeObject(webhookEvent, Formatting.Indented, DefaultSerializerSettings); + var bytes = UTF8.GetBytes(str); + return bytes; + } + + private static WebhookDeliveryData NewDelivery() + { + var delivery = new WebhookDeliveryData(); + delivery.Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(16)); + delivery.Timestamp = DateTimeOffset.UtcNow; + return delivery; + } + } +} diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index e3d3415ea..19e41ddd5 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -216,7 +216,8 @@ namespace BTCPayServer.Hosting services.AddSingleton(); services.AddSingleton(o => o.GetRequiredService()); - + services.AddSingleton(); + services.AddSingleton(o => o.GetRequiredService()); services.AddSingleton(); services.AddSingleton(o => o.GetRequiredService()); diff --git a/BTCPayServer/Models/InvoicingModels/InvoiceDetailsModel.cs b/BTCPayServer/Models/InvoicingModels/InvoiceDetailsModel.cs index 2052f3365..c27d1dd2c 100644 --- a/BTCPayServer/Models/InvoicingModels/InvoiceDetailsModel.cs +++ b/BTCPayServer/Models/InvoicingModels/InvoiceDetailsModel.cs @@ -79,9 +79,12 @@ namespace BTCPayServer.Models.InvoicingModels get; set; } + + public List Deliveries { get; set; } = new List(); public string TaxIncluded { get; set; } public string TransactionSpeed { get; set; } + public string StoreId { get; set; } public object StoreName { get; diff --git a/BTCPayServer/Models/StoreViewModels/EditWebhookViewModel.cs b/BTCPayServer/Models/StoreViewModels/EditWebhookViewModel.cs new file mode 100644 index 000000000..2a7600c70 --- /dev/null +++ b/BTCPayServer/Models/StoreViewModels/EditWebhookViewModel.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Threading.Tasks; +using BTCPayServer.Client.Models; +using BTCPayServer.Data; +using BTCPayServer.Validation; + +namespace BTCPayServer.Models.StoreViewModels +{ + public class DeliveryViewModel + { + public DeliveryViewModel() + { + + } + public DeliveryViewModel(WebhookDeliveryData s) + { + var blob = s.GetBlob(); + Id = s.Id; + Success = blob.Status == WebhookDeliveryStatus.HttpSuccess; + ErrorMessage = blob.ErrorMessage ?? "Success"; + Time = s.Timestamp; + Type = blob.ReadRequestAs().Type; + WebhookId = s.Id; + } + public string Id { get; set; } + public DateTimeOffset Time { get; set; } + public WebhookEventType Type { get; private set; } + public string WebhookId { get; set; } + public bool Success { get; set; } + public string ErrorMessage { get; set; } + } + public class EditWebhookViewModel + { + public EditWebhookViewModel() + { + + } + public EditWebhookViewModel(WebhookBlob blob) + { + Active = blob.Active; + AutomaticRedelivery = blob.AutomaticRedelivery; + Everything = blob.AuthorizedEvents.Everything; + Events = blob.AuthorizedEvents.SpecificEvents; + PayloadUrl = blob.Url; + Secret = blob.Secret; + IsNew = false; + } + public bool IsNew { get; set; } + public bool Active { get; set; } + public bool AutomaticRedelivery { get; set; } + public bool Everything { get; set; } + public WebhookEventType[] Events { get; set; } = Array.Empty(); + [Uri] + [Required] + public string PayloadUrl { get; set; } + [MaxLength(64)] + public string Secret { get; set; } + + public List Deliveries { get; set; } = new List(); + + public WebhookBlob CreateBlob() + { + return new WebhookBlob() + { + Active = Active, + Secret = Secret, + AutomaticRedelivery = AutomaticRedelivery, + Url = new Uri(PayloadUrl, UriKind.Absolute).AbsoluteUri, + AuthorizedEvents = new AuthorizedWebhookEvents() + { + Everything = Everything, + SpecificEvents = Events + } + }; + } + } +} diff --git a/BTCPayServer/Models/StoreViewModels/WebhooksViewModel.cs b/BTCPayServer/Models/StoreViewModels/WebhooksViewModel.cs new file mode 100644 index 000000000..b46bec728 --- /dev/null +++ b/BTCPayServer/Models/StoreViewModels/WebhooksViewModel.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace BTCPayServer.Models.StoreViewModels +{ + public class WebhooksViewModel + { + public class WebhookViewModel + { + public string Id { get; set; } + public string Url { get; set; } + } + public WebhookViewModel[] Webhooks { get; set; } + } +} diff --git a/BTCPayServer/Services/Invoices/InvoiceRepository.cs b/BTCPayServer/Services/Invoices/InvoiceRepository.cs index d9e704d79..edc2bf15f 100644 --- a/BTCPayServer/Services/Invoices/InvoiceRepository.cs +++ b/BTCPayServer/Services/Invoices/InvoiceRepository.cs @@ -2,12 +2,14 @@ using System; using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Runtime.CompilerServices; using System.Threading.Tasks; using BTCPayServer.Client.Models; using BTCPayServer.Data; using BTCPayServer.Events; using BTCPayServer.Logging; using BTCPayServer.Models.InvoicingModels; +using BTCPayServer.Models.StoreViewModels; using BTCPayServer.Payments; using DBriize; using Microsoft.EntityFrameworkCore; @@ -59,6 +61,15 @@ retry: _eventAggregator = eventAggregator; } + public async Task GetWebhookDelivery(string invoiceId, string deliveryId) + { + using var ctx = _ContextFactory.CreateContext(); + return await ctx.InvoiceWebhookDeliveries + .Where(d => d.InvoiceId == invoiceId && d.DeliveryId == deliveryId) + .Select(d => d.Delivery) + .FirstOrDefaultAsync(); + } + public InvoiceEntity CreateNewInvoice() { return new InvoiceEntity() @@ -107,6 +118,16 @@ retry: } } + public async Task> GetWebhookDeliveries(string invoiceId) + { + using var ctx = _ContextFactory.CreateContext(); + return await ctx.InvoiceWebhookDeliveries + .Where(s => s.InvoiceId == invoiceId) + .Select(s => s.Delivery) + .OrderByDescending(s => s.Timestamp) + .ToListAsync(); + } + public async Task GetAppsTaggingStore(string storeId) { if (storeId == null) diff --git a/BTCPayServer/Services/Stores/StoreRepository.cs b/BTCPayServer/Services/Stores/StoreRepository.cs index b41ff2f6f..18cca3803 100644 --- a/BTCPayServer/Services/Stores/StoreRepository.cs +++ b/BTCPayServer/Services/Stores/StoreRepository.cs @@ -13,7 +13,10 @@ namespace BTCPayServer.Services.Stores public class StoreRepository { private readonly ApplicationDbContextFactory _ContextFactory; - + public ApplicationDbContext CreateDbContext() + { + return _ContextFactory.CreateContext(); + } public StoreRepository(ApplicationDbContextFactory contextFactory) { _ContextFactory = contextFactory ?? throw new ArgumentNullException(nameof(contextFactory)); @@ -177,7 +180,7 @@ namespace BTCPayServer.Services.Stores ctx.Add(userStore); await ctx.SaveChangesAsync(); } - } + } public async Task CreateStore(string ownerId, string name) { @@ -193,6 +196,108 @@ namespace BTCPayServer.Services.Stores return store; } + public async Task GetWebhooks(string storeId) + { + using var ctx = _ContextFactory.CreateContext(); + return await ctx.StoreWebhooks + .Where(s => s.StoreId == storeId) + .Select(s => s.Webhook).ToArrayAsync(); + } + + public async Task GetWebhookDelivery(string storeId, string webhookId, string deliveryId) + { + using var ctx = _ContextFactory.CreateContext(); + return await ctx.StoreWebhooks + .Where(d => d.StoreId == storeId && d.WebhookId == webhookId) + .SelectMany(d => d.Webhook.Deliveries) + .Where(d => d.Id == deliveryId) + .FirstOrDefaultAsync(); + } + + public async Task AddWebhookDelivery(WebhookDeliveryData delivery) + { + using var ctx = _ContextFactory.CreateContext(); + ctx.WebhookDeliveries.Add(delivery); + var invoiceWebhookDelivery = delivery.GetBlob().ReadRequestAs(); + if (invoiceWebhookDelivery.InvoiceId != null) + { + ctx.InvoiceWebhookDeliveries.Add(new InvoiceWebhookDeliveryData() + { + InvoiceId = invoiceWebhookDelivery.InvoiceId, + DeliveryId = delivery.Id + }); + } + await ctx.SaveChangesAsync(); + } + + public async Task GetWebhookDeliveries(string storeId, string webhookId, int count) + { + using var ctx = _ContextFactory.CreateContext(); + return await ctx.StoreWebhooks + .Where(s => s.StoreId == storeId && s.WebhookId == webhookId) + .SelectMany(s => s.Webhook.Deliveries) + .OrderByDescending(s => s.Timestamp) + .Take(count) + .ToArrayAsync(); + } + + public async Task CreateWebhook(string storeId, WebhookBlob blob) + { + using var ctx = _ContextFactory.CreateContext(); + WebhookData data = new WebhookData(); + data.Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(16)); + data.SetBlob(blob); + StoreWebhookData storeWebhook = new StoreWebhookData(); + storeWebhook.StoreId = storeId; + storeWebhook.WebhookId = data.Id; + ctx.StoreWebhooks.Add(storeWebhook); + ctx.Webhooks.Add(data); + await ctx.SaveChangesAsync(); + return data.Id; + } + + public async Task GetWebhook(string storeId, string webhookId) + { + var ctx = _ContextFactory.CreateContext(); + return await ctx.StoreWebhooks + .Where(s => s.StoreId == storeId && s.WebhookId == webhookId) + .Select(s => s.Webhook) + .FirstOrDefaultAsync(); + } + public async Task GetWebhook(string webhookId) + { + var ctx = _ContextFactory.CreateContext(); + return await ctx.StoreWebhooks + .Where(s => s.WebhookId == webhookId) + .Select(s => s.Webhook) + .FirstOrDefaultAsync(); + } + public async Task DeleteWebhook(string storeId, string webhookId) + { + var ctx = _ContextFactory.CreateContext(); + var hook = await ctx.StoreWebhooks + .Where(s => s.StoreId == storeId && s.WebhookId == webhookId) + .Select(s => s.Webhook) + .FirstOrDefaultAsync(); + if (hook is null) + return; + ctx.Webhooks.Remove(hook); + await ctx.SaveChangesAsync(); + } + + public async Task UpdateWebhook(string storeId, string webhookId, WebhookBlob webhookBlob) + { + var ctx = _ContextFactory.CreateContext(); + var hook = await ctx.StoreWebhooks + .Where(s => s.StoreId == storeId && s.WebhookId == webhookId) + .Select(s => s.Webhook) + .FirstOrDefaultAsync(); + if (hook is null) + return; + hook.SetBlob(webhookBlob); + await ctx.SaveChangesAsync(); + } + public async Task RemoveStore(string storeId, string userId) { using (var ctx = _ContextFactory.CreateContext()) @@ -225,6 +330,11 @@ namespace BTCPayServer.Services.Stores var store = await ctx.Stores.FindAsync(storeId); if (store == null) return false; + var webhooks = await ctx.StoreWebhooks + .Select(o => o.Webhook) + .ToArrayAsync(); + foreach (var w in webhooks) + ctx.Webhooks.Remove(w); ctx.Stores.Remove(store); await ctx.SaveChangesAsync(); return true; diff --git a/BTCPayServer/Views/Invoice/Invoice.cshtml b/BTCPayServer/Views/Invoice/Invoice.cshtml index df19d0e79..1c7576254 100644 --- a/BTCPayServer/Views/Invoice/Invoice.cshtml +++ b/BTCPayServer/Views/Invoice/Invoice.cshtml @@ -57,7 +57,7 @@
-

Information

+

Information

@@ -110,7 +110,7 @@
Store
-

Buyer information

+

Buyer information

@@ -151,7 +151,7 @@
Name
@if (Model.PosData.Count == 0) { -

Product information

+

Product information

@@ -178,7 +178,7 @@ {
-

Product information

+

Product information

Item code
@@ -199,17 +199,69 @@
Item code
-

Point of Sale Data

+

Point of Sale Data

} + @if (Model.Deliveries.Count != 0) + { +

Webhook deliveries

+
    + @foreach (var delivery in Model.Deliveries) + { +
  • +
    +
    + + @if (delivery.Success) + { + + } + else + { + + } + + + @delivery.Id + + | + @delivery.Type + + + + + @delivery.Time.ToBrowserDate() + + + + +
    +
    +
  • + } +
+ }
-

Events

+

Events

diff --git a/BTCPayServer/Views/Invoice/ListInvoices.cshtml b/BTCPayServer/Views/Invoice/ListInvoices.cshtml index 4dae378fc..8069dacd9 100644 --- a/BTCPayServer/Views/Invoice/ListInvoices.cshtml +++ b/BTCPayServer/Views/Invoice/ListInvoices.cshtml @@ -5,7 +5,8 @@ var storeIds = string.Join("", Model.StoreIds.Select(storeId => $",storeid:{storeId}")); } @section HeadScripts { - + @*Without async, somehow selenium do not manage to click on links in this page*@ + } @Html.HiddenFor(a => a.Count)
diff --git a/BTCPayServer/Views/Invoice/ListInvoicesPaymentsPartial.cshtml b/BTCPayServer/Views/Invoice/ListInvoicesPaymentsPartial.cshtml index 719f63445..736a18fd7 100644 --- a/BTCPayServer/Views/Invoice/ListInvoicesPaymentsPartial.cshtml +++ b/BTCPayServer/Views/Invoice/ListInvoicesPaymentsPartial.cshtml @@ -1,4 +1,4 @@ -@using BTCPayServer.Client.Models +@using BTCPayServer.Client.Models @model (InvoiceDetailsModel Invoice, bool ShowAddress) @{ var invoice = Model.Invoice; } @inject PaymentMethodHandlerDictionary PaymentMethodHandlerDictionary @@ -31,7 +31,7 @@ @if (Model.ShowAddress) {
} diff --git a/BTCPayServer/Views/Stores/ModifyWebhook.cshtml b/BTCPayServer/Views/Stores/ModifyWebhook.cshtml new file mode 100644 index 000000000..8d1df1492 --- /dev/null +++ b/BTCPayServer/Views/Stores/ModifyWebhook.cshtml @@ -0,0 +1,157 @@ +@model EditWebhookViewModel +@using BTCPayServer.Client.Models; +@{ + Layout = "../Shared/_NavLayout.cshtml"; + ViewData.SetActivePageAndTitle(StoreNavPages.Webhooks, "Webhooks"); +} + + + +
+
+
+

Webhooks settings

+
+ + + +
+
+ +
+ +
+ + + +
+
+

The endpoint receiving the payload must validate the payload by checking that the HTTP header BTCPAY-SIG of the callback matches the HMAC256 of the secret on the payload's body bytes.

+
+
+
+ + +

We will try to redeliver any failed delivery after 10 seconds, 1 minutes and up to 6 times after 10 minutes

+
+
+
+
+ + +
+
+

Events

+
+ + +
+
+
    + @foreach (var evt in new[] +{ + ("A new invoice has been created", WebhookEventType.InvoiceCreated), + ("A new payment has been received", WebhookEventType.InvoiceReceivedPayment), + ("An invoice is fully paid", WebhookEventType.InvoicePaidInFull), + ("An invoice has expired", WebhookEventType.InvoiceExpired), + ("An invoice has been confirmed", WebhookEventType.InvoiceConfirmed), + ("An invoice became invalid", WebhookEventType.InvoiceInvalid) + }) + { +
  • +
    + + + + + + +
    +
  • + } +
+
+ @if (Model.IsNew) + { + + } + else + { + + } + + @if (!Model.IsNew && Model.Deliveries.Count > 0) + { +

Recent deliveries

+
    + @foreach (var delivery in Model.Deliveries) + { +
  • +
    +
    + + @if (delivery.Success) + { + + } + else + { + + } + + + @delivery.Id + + + + + + @delivery.Time.ToBrowserDate() + + + + +
    + +
  • + } +
+ } +
+
+ +@section Scripts { + @await Html.PartialAsync("_ValidationScriptsPartial") + + +} diff --git a/BTCPayServer/Views/Stores/StoreNavPages.cs b/BTCPayServer/Views/Stores/StoreNavPages.cs index 13f1873f8..e20293378 100644 --- a/BTCPayServer/Views/Stores/StoreNavPages.cs +++ b/BTCPayServer/Views/Stores/StoreNavPages.cs @@ -2,6 +2,6 @@ namespace BTCPayServer.Views.Stores { public enum StoreNavPages { - ActivePage, Index, Rates, Checkout, Tokens, Users, PayButton, Integrations + ActivePage, Index, Rates, Checkout, Tokens, Users, PayButton, Integrations, Webhooks } } diff --git a/BTCPayServer/Views/Stores/UpdateStore.cshtml b/BTCPayServer/Views/Stores/UpdateStore.cshtml index 45186cc82..76c8be59f 100644 --- a/BTCPayServer/Views/Stores/UpdateStore.cshtml +++ b/BTCPayServer/Views/Stores/UpdateStore.cshtml @@ -336,11 +336,11 @@ @if (Model.CanDelete) {

Other actions

- } diff --git a/BTCPayServer/Views/Stores/Webhooks.cshtml b/BTCPayServer/Views/Stores/Webhooks.cshtml new file mode 100644 index 000000000..56956bb44 --- /dev/null +++ b/BTCPayServer/Views/Stores/Webhooks.cshtml @@ -0,0 +1,46 @@ +@model WebhooksViewModel +@{ + Layout = "../Shared/_NavLayout.cshtml"; + ViewData.SetActivePageAndTitle(StoreNavPages.Webhooks, "Webhooks"); +} + + + +

Webhooks

+
+
+

Webhooks allows BTCPayServer to send HTTP events related to your store

+
+
+ +
+
+ Create a new webhook + @if (Model.Webhooks.Any()) + { +
- @payment.Address + @payment.Address @payment.Rate
+ + + + + + + + @foreach (var wh in Model.Webhooks) + { + + + + + } + +
UrlActions
@wh.Url + Modify - Delete +
+ } +
+
+ +@section Scripts { + @await Html.PartialAsync("_ValidationScriptsPartial") +} diff --git a/BTCPayServer/Views/Stores/_Nav.cshtml b/BTCPayServer/Views/Stores/_Nav.cshtml index 61ab575e4..7c9416d4f 100644 --- a/BTCPayServer/Views/Stores/_Nav.cshtml +++ b/BTCPayServer/Views/Stores/_Nav.cshtml @@ -6,6 +6,7 @@ Users Pay Button Integrations + Webhooks diff --git a/BTCPayServer/wwwroot/main/site.js b/BTCPayServer/wwwroot/main/site.js index d682e320e..c1c4c1ec1 100644 --- a/BTCPayServer/wwwroot/main/site.js +++ b/BTCPayServer/wwwroot/main/site.js @@ -92,3 +92,35 @@ function switchTimeFormat() { $(this).attr("data-switch", htmlVal); }); } + +/** + * @author Abdo-Hamoud + * https://github.com/Abdo-Hamoud/bootstrap-show-password + * version: 1.0 + */ + +!function ($) { + //eyeOpenClass: 'fa-eye', + //eyeCloseClass: 'fa-eye-slash', + 'use strict'; + + $(function () { + $('[data-toggle="password"]').each(function () { + var input = $(this); + var eye_btn = $(this).parent().find('.input-group-text'); + eye_btn.css('cursor', 'pointer').addClass('input-password-hide'); + eye_btn.on('click', function () { + if (eye_btn.hasClass('input-password-hide')) { + eye_btn.removeClass('input-password-hide').addClass('input-password-show'); + eye_btn.find('.fa').removeClass('fa-eye').addClass('fa-eye-slash') + input.attr('type', 'text'); + } else { + eye_btn.removeClass('input-password-show').addClass('input-password-hide'); + eye_btn.find('.fa').removeClass('fa-eye-slash').addClass('fa-eye') + input.attr('type', 'password'); + } + }); + }); + }); + +}(window.jQuery);