Add Webhooks in store's settings

This commit is contained in:
nicolas.dorier 2020-11-06 20:42:26 +09:00
parent cc6fe24e82
commit f3611ac693
No known key found for this signature in database
GPG key ID: 6618763EF09186FE
38 changed files with 1756 additions and 28 deletions

View file

@ -0,0 +1,13 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace BTCPayServer.Client.Models
{
public enum WebhookDeliveryStatus
{
Failed,
HttpError,
HttpSuccess
}
}

View file

@ -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<string, JToken> AdditionalData { get; set; }
}
}

View file

@ -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
}
}

View file

@ -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; }
}
}

View file

@ -63,6 +63,11 @@ namespace BTCPayServer.Data
public DbSet<U2FDevice> U2FDevices { get; set; }
public DbSet<NotificationData> Notifications { get; set; }
public DbSet<StoreWebhookData> StoreWebhooks { get; set; }
public DbSet<WebhookData> Webhooks { get; set; }
public DbSet<WebhookDeliveryData> WebhookDeliveries { get; set; }
public DbSet<InvoiceWebhookDeliveryData> InvoiceWebhookDeliveries { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
var isConfigured = optionsBuilder.Options.Extensions.OfType<RelationalOptionsExtension>().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

View file

@ -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<InvoiceWebhookDeliveryData>()
.HasKey(p => new { p.InvoiceId, p.DeliveryId });
builder.Entity<InvoiceWebhookDeliveryData>()
.HasOne(o => o.Invoice)
.WithOne().OnDelete(DeleteBehavior.Cascade);
builder.Entity<InvoiceWebhookDeliveryData>()
.HasOne(o => o.Delivery)
.WithOne().OnDelete(DeleteBehavior.Cascade);
}
}
}

View file

@ -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<StoreWebhookData>()
.HasKey(p => new { p.StoreId, p.WebhookId });
builder.Entity<StoreWebhookData>()
.HasOne(o => o.Webhook)
.WithOne().OnDelete(DeleteBehavior.Cascade);
builder.Entity<StoreWebhookData>()
.HasOne(o => o.Store)
.WithOne().OnDelete(DeleteBehavior.Cascade);
}
}
}

View file

@ -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<WebhookDeliveryData> Deliveries { get; set; }
}
}

View file

@ -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<WebhookDeliveryData>()
.HasOne(o => o.Webhook)
.WithMany(a => a.Deliveries).OnDelete(DeleteBehavior.Cascade);
builder.Entity<WebhookDeliveryData>().HasIndex(o => o.WebhookId);
}
}
}

View file

@ -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<string>(maxLength: 25, nullable: false),
Blob = table.Column<byte[]>(nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Webhooks", x => x.Id);
});
migrationBuilder.CreateTable(
name: "StoreWebhooks",
columns: table => new
{
StoreId = table.Column<string>(nullable: false),
WebhookId = table.Column<string>(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<string>(maxLength: 25, nullable: false),
WebhookId = table.Column<string>(maxLength: 25, nullable: false),
Timestamp = table.Column<DateTimeOffset>(nullable: false),
Blob = table.Column<byte[]>(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<string>(nullable: false),
DeliveryId = table.Column<string>(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");
}
}
}

View file

@ -257,6 +257,25 @@ namespace BTCPayServer.Migrations
b.ToTable("InvoiceEvents");
});
modelBuilder.Entity("BTCPayServer.Data.InvoiceWebhookDeliveryData", b =>
{
b.Property<string>("InvoiceId")
.HasColumnType("TEXT");
b.Property<string>("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<string>("Id")
@ -588,6 +607,25 @@ namespace BTCPayServer.Migrations
b.ToTable("Stores");
});
modelBuilder.Entity("BTCPayServer.Data.StoreWebhookData", b =>
{
b.Property<string>("StoreId")
.HasColumnType("TEXT");
b.Property<string>("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<string>("Id")
@ -696,6 +734,46 @@ namespace BTCPayServer.Migrations
b.ToTable("WalletTransactions");
});
modelBuilder.Entity("BTCPayServer.Data.WebhookData", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasMaxLength(25);
b.Property<byte[]>("Blob")
.IsRequired()
.HasColumnType("BLOB");
b.HasKey("Id");
b.ToTable("Webhooks");
});
modelBuilder.Entity("BTCPayServer.Data.WebhookDeliveryData", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasMaxLength(25);
b.Property<byte[]>("Blob")
.IsRequired()
.HasColumnType("BLOB");
b.Property<DateTimeOffset>("Timestamp")
.HasColumnType("TEXT");
b.Property<string>("WebhookId")
.IsRequired()
.HasColumnType("TEXT")
.HasMaxLength(25);
b.HasKey("Id");
b.HasIndex("WebhookId");
b.ToTable("WebhookDeliveries");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("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<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)

View file

@ -21,6 +21,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.6.1" />
<PackageReference Include="Newtonsoft.Json.Schema" Version="3.0.13" />
<PackageReference Include="Selenium.Support" Version="3.141.0" />
<PackageReference Include="Selenium.WebDriver" Version="3.141.0" />
<PackageReference Include="Selenium.WebDriver.ChromeDriver" Version="85.0.4183.8700" />
<PackageReference Include="xunit" Version="2.4.1" />

View file

@ -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<bool> taskCompletion)
{
TaskCompletion = taskCompletion;
}
public HttpContext HttpContext { get; set; }
public TaskCompletionSource<bool> TaskCompletion { get; }
public void Complete()
{
TaskCompletion.SetResult(true);
}
}
readonly IWebHost _Host = null;
readonly CancellationTokenSource _Closed = new CancellationTokenSource();
readonly Channel<RawRequest> _Requests = Channel.CreateUnbounded<RawRequest>();
public RawHttpServer()
{
var port = Utils.FreeTcpPort();
_Host = new WebHostBuilder()
.Configure(app =>
{
app.Run(req =>
{
var cts = new TaskCompletionSource<bool>(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<IServerAddressesFeature>().Addresses.First());
}
public async Task<RawRequest> 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();
}
}
}

View file

@ -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;
}

View file

@ -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()

View file

@ -999,7 +999,6 @@ namespace BTCPayServer.Tests
}
}
}
var invoice2 = acc.BitPay.GetInvoice(invoice.Id);
Assert.NotNull(invoice2);
}

View file

@ -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<IActionResult> 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<IActionResult> 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

View file

@ -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;
}

View file

@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> Integrations([FromServices] IHttpClientFactory clientFactory,

View file

@ -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")]

View file

@ -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<WebhookEventType>();
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<T>()
{
return JsonConvert.DeserializeObject<T>(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<WebhookBlob>(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<WebhookDeliveryBlob>(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));
}
}
}

View file

@ -16,7 +16,7 @@ namespace BTCPayServer.HostedServices
private List<IEventAggregatorSubscription> _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<T>(e => _Events.Writer.TryWrite(e)));
}
protected void PushEvent(object obj)
{
_Events.Writer.TryWrite(obj);
}
public virtual Task StartAsync(CancellationToken cancellationToken)
{
_Subscriptions = new List<IEventAggregatorSubscription>();

View file

@ -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
{
/// <summary>
/// This class send webhook notifications
/// It also make sure the events sent to a webhook are sent in order to the webhook
/// </summary>
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<string, Channel<WebhookDeliveryRequest>> _InvoiceEventsByWebhookId = new Dictionary<string, Channel<WebhookDeliveryRequest>>();
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<InvoiceEvent>();
}
public async Task<string> Redeliver(string deliveryId)
{
var deliveryRequest = await CreateRedeliveryRequest(deliveryId);
EnqueueDelivery(deliveryRequest);
return deliveryRequest.Delivery.Id;
}
private async Task<WebhookDeliveryRequest> 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>();
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<WebhookDeliveryRequest>();
_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<WebhookDeliveryRequest> 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<DeliveryResult> 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;
}
}
}

View file

@ -216,7 +216,8 @@ namespace BTCPayServer.Hosting
services.AddSingleton<HostedServices.CheckConfigurationHostedService>();
services.AddSingleton<IHostedService, HostedServices.CheckConfigurationHostedService>(o => o.GetRequiredService<CheckConfigurationHostedService>());
services.AddSingleton<HostedServices.WebhookNotificationManager>();
services.AddSingleton<IHostedService, WebhookNotificationManager>(o => o.GetRequiredService<WebhookNotificationManager>());
services.AddSingleton<HostedServices.PullPaymentHostedService>();
services.AddSingleton<IHostedService, HostedServices.PullPaymentHostedService>(o => o.GetRequiredService<PullPaymentHostedService>());

View file

@ -79,9 +79,12 @@ namespace BTCPayServer.Models.InvoicingModels
get;
set;
}
public List<StoreViewModels.DeliveryViewModel> Deliveries { get; set; } = new List<StoreViewModels.DeliveryViewModel>();
public string TaxIncluded { get; set; }
public string TransactionSpeed { get; set; }
public string StoreId { get; set; }
public object StoreName
{
get;

View file

@ -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<WebhookEvent>().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<WebhookEventType>();
[Uri]
[Required]
public string PayloadUrl { get; set; }
[MaxLength(64)]
public string Secret { get; set; }
public List<DeliveryViewModel> Deliveries { get; set; } = new List<DeliveryViewModel>();
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
}
};
}
}
}

View file

@ -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; }
}
}

View file

@ -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<WebhookDeliveryData> 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<List<WebhookDeliveryData>> 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<AppData[]> GetAppsTaggingStore(string storeId)
{
if (storeId == null)

View file

@ -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<StoreData> CreateStore(string ownerId, string name)
{
@ -193,6 +196,108 @@ namespace BTCPayServer.Services.Stores
return store;
}
public async Task<WebhookData[]> 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<WebhookDeliveryData> 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<InvoiceWebhookDeliveryData>();
if (invoiceWebhookDelivery.InvoiceId != null)
{
ctx.InvoiceWebhookDeliveries.Add(new InvoiceWebhookDeliveryData()
{
InvoiceId = invoiceWebhookDelivery.InvoiceId,
DeliveryId = delivery.Id
});
}
await ctx.SaveChangesAsync();
}
public async Task<WebhookDeliveryData[]> 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<string> 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<WebhookData> 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<WebhookData> 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;

View file

@ -57,7 +57,7 @@
<div class="row">
<div class="col-md-6">
<h3>Information</h3>
<h3 class="mb-3">Information</h3>
<table class="table table-sm table-responsive-md removetopborder">
<tr>
<th>Store</th>
@ -110,7 +110,7 @@
</table>
</div>
<div class="col-md-6">
<h3>Buyer information</h3>
<h3 class="mb-3">Buyer information</h3>
<table class="table table-sm table-responsive-md removetopborder">
<tr>
<th>Name</th>
@ -151,7 +151,7 @@
</table>
@if (Model.PosData.Count == 0)
{
<h3>Product information</h3>
<h3 class="mb-3">Product information</h3>
<table class="table table-sm table-responsive-md removetopborder">
<tr>
<th>Item code</th>
@ -178,7 +178,7 @@
{
<div class="row">
<div class="col-md-6">
<h3>Product information</h3>
<h3 class="mb-3">Product information</h3>
<table class="table table-sm table-responsive-md removetopborder">
<tr>
<th>Item code</th>
@ -199,17 +199,69 @@
</table>
</div>
<div class="col-md-6">
<h3>Point of Sale Data</h3>
<h3 class="mb-3">Point of Sale Data</h3>
<partial name="PosData" model="@Model.PosData" />
</div>
</div>
}
<partial name="ListInvoicesPaymentsPartial" model="(Model, true)" />
@if (Model.Deliveries.Count != 0)
{
<h3 class="mb-3">Webhook deliveries</h3>
<ul class="list-group mb-5">
@foreach (var delivery in Model.Deliveries)
{
<li class="list-group-item ">
<form
asp-action="RedeliverWebhook"
asp-route-storeId="@Model.StoreId"
asp-route-invoiceId="@Model.Id"
asp-route-deliveryId="@delivery.Id"
method="post">
<div class="d-flex align-items-center">
<span class="d-flex align-items-center flex-fill mr-3">
@if (delivery.Success)
{
<span class="d-flex align-items-center fa fa-check text-success" title="Success"></span>
}
else
{
<span class="d-flex align-items-center fa fa-times text-danger" title="@delivery.ErrorMessage"></span>
}
<span class="ml-3">
<a
asp-action="WebhookDelivery"
asp-route-invoiceId="@Model.Id"
asp-route-deliveryId="@delivery.Id"
class="btn btn-link delivery-content" target="_blank">
@delivery.Id
</a>
<span class="text-light mx-2">|</span>
<span class="small text-muted">@delivery.Type</span>
</span>
</span>
<span class="d-flex align-items-center">
<strong class="d-flex align-items-center text-muted small">
@delivery.Time.ToBrowserDate()
</strong>
<button id="#redeliver-@delivery.Id"
type="submit"
class="btn btn-info py-1 ml-3 redeliver">
Redeliver
</button>
</span>
</div>
</form>
</li>
}
</ul>
}
<div class="row">
<div class="col-md-12">
<h3>Events</h3>
<h3 class="mb-3">Events</h3>
<table class="table table-sm table-responsive-md">
<thead class="thead-inverse">
<tr>

View file

@ -5,7 +5,8 @@
var storeIds = string.Join("", Model.StoreIds.Select(storeId => $",storeid:{storeId}"));
}
@section HeadScripts {
<script src="~/modal/btcpay.js" asp-append-version="true"></script>
@*Without async, somehow selenium do not manage to click on links in this page*@
<script src="~/modal/btcpay.js" asp-append-version="true" async></script>
}
@Html.HiddenFor(a => a.Count)
<section>

View file

@ -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)
{
<td title="@payment.Address">
<span class="text-truncate d-block" style="max-width: 400px">@payment.Address</span>
<span class="text-truncate d-block" style="max-width: 400px">@payment.Address</span>
</td>
}
<td class="text-right">@payment.Rate</td>

View file

@ -0,0 +1,157 @@
@model EditWebhookViewModel
@using BTCPayServer.Client.Models;
@{
Layout = "../Shared/_NavLayout.cshtml";
ViewData.SetActivePageAndTitle(StoreNavPages.Webhooks, "Webhooks");
}
<partial name="_StatusMessage" />
<div class="row">
<div class="col-md-8">
<form method="post">
<h4 class="mb-3">Webhooks settings</h4>
<div class="form-group">
<label asp-for="PayloadUrl">Payload URL</label>
<input asp-for="PayloadUrl" class="form-control" />
<span asp-validation-for="PayloadUrl" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Secret"></label>
<div class="input-group">
<input asp-for="Secret" type="password" class="form-control" value="@Model.Secret" data-toggle="password">
<div class="input-group-append">
<span class="input-group-text">
<i class="fa fa-eye"></i>
</span>
</div>
</div>
<p class="text-muted small form-text">The endpoint receiving the payload must validate the payload by checking that the HTTP header <code>BTCPAY-SIG</code> of the callback matches the HMAC256 of the secret on the payload's body bytes.</p>
</div>
<div class="form-group">
<div class="form-check">
<input asp-for="AutomaticRedelivery" type="checkbox" class="form-check-input" />
<label asp-for="AutomaticRedelivery" class="form-check-label">Automatic redelivery</label>
<p class="text-muted small form-text">We will try to redeliver any failed delivery after 10 seconds, 1 minutes and up to 6 times after 10 minutes</p>
</div>
</div>
<div class="form-group mb-5">
<div class="form-check">
<input asp-for="Active" type="checkbox" class="form-check-input" />
<label asp-for="Active" class="form-check-label">Is enabled</label>
</div>
</div>
<h4 class="mb-3">Events</h4>
<div class="form-group">
<label asp-for="Everything">Which events would you like to trigger this webhook?</label>
<select id="all-events" class="form-control" asp-for="Everything">
<option value="true">Send me everything</option>
<option value="false">Send specific events</option>
</select>
</div>
<div id="event-selector" class="collapse">
<ul class="list-group">
@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)
})
{
<li class="list-group-item ">
<div class="d-flex align-items-center">
<span class="d-flex align-items-center flex-fill mr-3">
<label for="@evt.Item2" class="form-check-label">@evt.Item1</label>
</span>
<span class="d-flex align-items-center">
<input name="Events" id="@evt.Item2" value="@evt.Item2" @(Model.Events.Contains(evt.Item2) ? "checked" : "") type="checkbox" class="form-check-input" />
</span>
</div>
</li>
}
</ul>
</div>
@if (Model.IsNew)
{
<button name="add" type="submit" class="btn btn-primary mt-3 mb-5" value="New" id="New">Add webhook</button>
}
else
{
<button name="update" type="submit" class="btn btn-primary mt-3 mb-5" value="Save" id="Save">Update webhook</button>
}
</form>
@if (!Model.IsNew && Model.Deliveries.Count > 0)
{
<h4 class="mb-3">Recent deliveries</h4>
<ul class="list-group">
@foreach (var delivery in Model.Deliveries)
{
<li class="list-group-item ">
<form asp-action="RedeliverWebhook"
asp-route-storeId="@this.Context.GetRouteValue("storeId")"
asp-route-webhookId="@this.Context.GetRouteValue("webhookId")"
asp-route-deliveryId="@delivery.Id"
method="post">
<div class="d-flex align-items-center">
<span class="d-flex align-items-center flex-fill mr-3">
@if (delivery.Success)
{
<span class="d-flex align-items-center fa fa-check text-success" title="Success"></span>
}
else
{
<span class="d-flex align-items-center fa fa-times text-danger" title="@delivery.ErrorMessage"></span>
}
<span class="ml-3">
<a asp-action="WebhookDelivery"
asp-route-storeId="@this.Context.GetRouteValue("storeId")"
asp-route-webhookId="@this.Context.GetRouteValue("webhookId")"
asp-route-deliveryId="@delivery.Id"
class="btn btn-link delivery-content" target="_blank">
@delivery.Id
</a>
</span>
</span>
<span class="d-flex align-items-center">
<strong class="d-flex align-items-center text-muted small">
@delivery.Time.ToBrowserDate()
</strong>
<button id="#redeliver-@delivery.Id"
type="submit"
class="btn btn-info py-1 ml-3 redeliver">
Redeliver
</button>
</span>
</div>
</form>
</li>
}
</ul>
}
</div>
</div>
@section Scripts {
@await Html.PartialAsync("_ValidationScriptsPartial")
<script type="text/javascript">
function toggleEventSelector() {
if ($("#all-events").val() === "true") {
$("#event-selector").hide();
}
else {
$("#event-selector").show();
}
}
$(function () {
toggleEventSelector();
$("#all-events").change(function () {
toggleEventSelector();
});
});
</script>
}

View file

@ -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
}
}

View file

@ -336,11 +336,11 @@
@if (Model.CanDelete)
{
<h4 class="mt-5 mb-3">Other actions</h4>
<button class="btn btn-link text-secondary mb-3 p-0" type="button" data-toggle="collapse" data-target="#danger-zone">
<button id="danger-zone-expander" class="btn btn-link text-secondary mb-3 p-0" type="button" data-toggle="collapse" data-target="#danger-zone">
See more actions
</button>
<div id="danger-zone" class="collapse">
<a class="btn btn-outline-danger mb-5" asp-action="DeleteStore" asp-route-storeId="@Model.Id">Delete this store</a>
<a id="delete-store" class="btn btn-outline-danger mb-5" asp-action="DeleteStore" asp-route-storeId="@Model.Id">Delete this store</a>
</div>
}
</div>

View file

@ -0,0 +1,46 @@
@model WebhooksViewModel
@{
Layout = "../Shared/_NavLayout.cshtml";
ViewData.SetActivePageAndTitle(StoreNavPages.Webhooks, "Webhooks");
}
<partial name="_StatusMessage" />
<h4>Webhooks</h4>
<div class="row">
<div class="col-md-8">
<p>Webhooks allows BTCPayServer to send HTTP events related to your store</p>
</div>
</div>
<div class="row">
<div class="col-md-8">
<a id="CreateWebhook" asp-action="NewWebhook" class="btn btn-primary" role="button" asp-route-storeId="@this.Context.GetRouteValue("storeId")"><span class="fa fa-plus"></span> Create a new webhook</a>
@if (Model.Webhooks.Any())
{
<table class="table table-sm table-responsive-md">
<thead>
<tr>
<th>Url</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var wh in Model.Webhooks)
{
<tr>
<td class="text-truncate d-block" style="max-width:300px;">@wh.Url</td>
<td class="text-right">
<a asp-action="ModifyWebhook" asp-route-storeId="@this.Context.GetRouteValue("storeId")" asp-route-webhookId="@wh.Id">Modify</a> - <a asp-action="DeleteWebhook" asp-route-storeId="@this.Context.GetRouteValue("storeId")" asp-route-webhookId="@wh.Id">Delete</a>
</td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
@section Scripts {
@await Html.PartialAsync("_ValidationScriptsPartial")
}

View file

@ -6,6 +6,7 @@
<a id="@(nameof(StoreNavPages.Users))" class="nav-link @ViewData.IsActivePage(StoreNavPages.Users)" asp-controller="Stores" asp-action="StoreUsers" asp-route-storeId="@this.Context.GetRouteValue("storeId")">Users</a>
<a id="@(nameof(StoreNavPages.PayButton))" class="nav-link @ViewData.IsActivePage(StoreNavPages.PayButton)" asp-controller="Stores" asp-action="PayButton" asp-route-storeId="@this.Context.GetRouteValue("storeId")">Pay Button</a>
<a id="@(nameof(StoreNavPages.Integrations))" class="nav-link @ViewData.IsActivePage(StoreNavPages.Integrations)" asp-controller="Stores" asp-action="Integrations" asp-route-storeId="@this.Context.GetRouteValue("storeId")">Integrations</a>
<a id="@(nameof(StoreNavPages.Webhooks))" class="nav-link @ViewData.IsActivePage(StoreNavPages.Webhooks)" asp-controller="Stores" asp-action="Webhooks" asp-route-storeId="@this.Context.GetRouteValue("storeId")">Webhooks</a>
<vc:ui-extension-point location="store-nav" />
</div>

View file

@ -92,3 +92,35 @@ function switchTimeFormat() {
$(this).attr("data-switch", htmlVal);
});
}
/**
* @author Abdo-Hamoud <abdo.host@gmail.com>
* 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);