Merge pull request #2058 from NicolasDorier/webhook2

Add Webhooks in store's settings
This commit is contained in:
Nicolas Dorier 2020-11-19 12:17:34 +09:00 committed by GitHub
commit 9c5fd1b478
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
59 changed files with 3288 additions and 48 deletions

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace BTCPayServer.Client
{

View file

@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Client
{
public partial class BTCPayServerClient
{
public async Task<StoreWebhookData> CreateWebhook(string storeId, Client.Models.CreateStoreWebhookRequest create, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/webhooks", bodyPayload: create, method: HttpMethod.Post), token);
return await HandleResponse<StoreWebhookData>(response);
}
public async Task<StoreWebhookData> GetWebhook(string storeId, string webhookId, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/webhooks/{webhookId}"), token);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return null;
return await HandleResponse<StoreWebhookData>(response);
}
public async Task<StoreWebhookData> UpdateWebhook(string storeId, string webhookId, Models.UpdateStoreWebhookRequest update, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/webhooks/{webhookId}", bodyPayload: update, method: HttpMethod.Put), token);
return await HandleResponse<StoreWebhookData>(response);
}
public async Task<bool> DeleteWebhook(string storeId, string webhookId, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/webhooks/{webhookId}", method: HttpMethod.Delete), token);
return response.IsSuccessStatusCode;
}
public async Task<StoreWebhookData[]> GetWebhooks(string storeId, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/webhooks"), token);
return await HandleResponse<StoreWebhookData[]>(response);
}
public async Task<WebhookDeliveryData[]> GetWebhookDeliveries(string storeId, string webhookId, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/webhooks/{webhookId}/deliveries"), token);
return await HandleResponse<WebhookDeliveryData[]>(response);
}
public async Task<WebhookDeliveryData> GetWebhookDelivery(string storeId, string webhookId, string deliveryId, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/webhooks/{webhookId}/deliveries/{deliveryId}"), token);
return await HandleResponse<WebhookDeliveryData>(response);
}
public async Task<string> RedeliverWebhook(string storeId, string webhookId, string deliveryId, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/webhooks/{webhookId}/deliveries/{deliveryId}/redeliver", null, HttpMethod.Post), token);
return await HandleResponse<string>(response);
}
public async Task<WebhookEvent> GetWebhookDeliveryRequest(string storeId, string webhookId, string deliveryId, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/webhooks/{webhookId}/deliveries/{deliveryId}/request"), token);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return null;
return await HandleResponse<WebhookEvent>(response);
}
}
}

View file

@ -65,7 +65,8 @@ namespace BTCPayServer.Client
protected async Task<T> HandleResponse<T>(HttpResponseMessage message)
{
await HandleResponse(message);
return JsonConvert.DeserializeObject<T>(await message.Content.ReadAsStringAsync());
var str = await message.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<T>(str);
}
protected virtual HttpRequestMessage CreateHttpRequest(string path,

View file

@ -9,4 +9,4 @@ namespace BTCPayServer.Client.Models
Complete,
Confirmed
}
}
}

View file

@ -0,0 +1,35 @@
using System;
using System.Collections.Generic;
using System.Text;
using Newtonsoft.Json;
namespace BTCPayServer.Client.Models
{
public class StoreWebhookBaseData
{
public class AuthorizedEventsData
{
public bool Everything { get; set; } = true;
[JsonProperty(ItemConverterType = typeof(Newtonsoft.Json.Converters.StringEnumConverter))]
public WebhookEventType[] SpecificEvents { get; set; } = Array.Empty<WebhookEventType>();
}
public bool Enabled { get; set; } = true;
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
public string Secret { get; set; }
public bool AutomaticRedelivery { get; set; } = true;
public string Url { get; set; }
public AuthorizedEventsData AuthorizedEvents { get; set; } = new AuthorizedEventsData();
}
public class UpdateStoreWebhookRequest : StoreWebhookBaseData
{
}
public class CreateStoreWebhookRequest : StoreWebhookBaseData
{
}
public class StoreWebhookData : StoreWebhookBaseData
{
public string Id { get; set; }
}
}

View file

@ -0,0 +1,18 @@
using System;
using System.Collections.Generic;
using System.Text;
using Newtonsoft.Json;
namespace BTCPayServer.Client.Models
{
public class WebhookDeliveryData
{
public string Id { get; set; }
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTimeOffset Timestamp { get; set; }
public int? HttpCode { get; set; }
public string ErrorMessage { get; set; }
[JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))]
public WebhookDeliveryStatus Status { get; set; }
}
}

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,36 @@
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 readonly static JsonSerializerSettings DefaultSerializerSettings;
static WebhookEvent()
{
DefaultSerializerSettings = new JsonSerializerSettings();
DefaultSerializerSettings.ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver();
NBitcoin.JsonConverters.Serializer.RegisterFrontConverters(DefaultSerializerSettings);
DefaultSerializerSettings.Formatting = Formatting.None;
}
public string DeliveryId { get; set; }
public string WebhookId { get; set; }
public string OrignalDeliveryId { get; set; }
public bool IsRedelivery { 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; }
public T ReadAs<T>()
{
var str = JsonConvert.SerializeObject(this, DefaultSerializerSettings);
return JsonConvert.DeserializeObject<T>(str, DefaultSerializerSettings);
}
}
}

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,86 @@
using System;
using System.Collections.Generic;
using System.Text;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace BTCPayServer.Client.Models
{
public class WebhookInvoiceEvent : WebhookEvent
{
public WebhookInvoiceEvent()
{
}
public WebhookInvoiceEvent(WebhookEventType evtType)
{
this.Type = evtType;
}
[JsonProperty(Order = 1)]
public string StoreId { get; set; }
[JsonProperty(Order = 2)]
public string InvoiceId { get; set; }
}
public class WebhookInvoiceConfirmedEvent : WebhookInvoiceEvent
{
public WebhookInvoiceConfirmedEvent()
{
}
public WebhookInvoiceConfirmedEvent(WebhookEventType evtType) : base(evtType)
{
}
public bool ManuallyMarked { get; set; }
}
public class WebhookInvoiceInvalidEvent : WebhookInvoiceEvent
{
public WebhookInvoiceInvalidEvent()
{
}
public WebhookInvoiceInvalidEvent(WebhookEventType evtType) : base(evtType)
{
}
public bool ManuallyMarked { get; set; }
}
public class WebhookInvoicePaidEvent : WebhookInvoiceEvent
{
public WebhookInvoicePaidEvent()
{
}
public WebhookInvoicePaidEvent(WebhookEventType evtType) : base(evtType)
{
}
public bool OverPaid { get; set; }
public bool PaidAfterExpiration { get; set; }
}
public class WebhookInvoiceReceivedPaymentEvent : WebhookInvoiceEvent
{
public WebhookInvoiceReceivedPaymentEvent()
{
}
public WebhookInvoiceReceivedPaymentEvent(WebhookEventType evtType) : base(evtType)
{
}
public bool AfterExpiration { get; set; }
}
public class WebhookInvoiceExpiredEvent : WebhookInvoiceEvent
{
public WebhookInvoiceExpiredEvent()
{
}
public WebhookInvoiceExpiredEvent(WebhookEventType evtType) : base(evtType)
{
}
public bool PartiallyPaid { get; set; }
}
}

View file

@ -12,6 +12,7 @@ namespace BTCPayServer.Client
public const string CanUseLightningNodeInStore = "btcpay.store.canuselightningnode";
public const string CanModifyServerSettings = "btcpay.server.canmodifyserversettings";
public const string CanModifyStoreSettings = "btcpay.store.canmodifystoresettings";
public const string CanModifyStoreWebhooks = "btcpay.store.webhooks.canmodifywebhooks";
public const string CanModifyStoreSettingsUnscoped = "btcpay.store.canmodifystoresettings:";
public const string CanViewStoreSettings = "btcpay.store.canviewstoresettings";
public const string CanViewInvoices = "btcpay.store.canviewinvoices";
@ -29,6 +30,7 @@ namespace BTCPayServer.Client
{
yield return CanViewInvoices;
yield return CanCreateInvoice;
yield return CanModifyStoreWebhooks;
yield return CanModifyServerSettings;
yield return CanModifyStoreSettings;
yield return CanViewStoreSettings;
@ -156,6 +158,7 @@ namespace BTCPayServer.Client
switch (subpolicy)
{
case Policies.CanViewInvoices when this.Policy == Policies.CanModifyStoreSettings:
case Policies.CanModifyStoreWebhooks when this.Policy == Policies.CanModifyStoreSettings:
case Policies.CanViewInvoices when this.Policy == Policies.CanViewStoreSettings:
case Policies.CanViewStoreSettings when this.Policy == Policies.CanModifyStoreSettings:
case Policies.CanCreateInvoice when this.Policy == Policies.CanModifyStoreSettings:

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

@ -89,7 +89,7 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("AddApiKey")).Click();
s.Driver.FindElement(By.CssSelector("button[value='btcpay.store.canmodifystoresettings:change-store-mode']")).Click();
//there should be a store already by default in the dropdown
var dropdown = s.Driver.FindElement(By.Name("PermissionValues[3].SpecificStores[0]"));
var dropdown = s.Driver.FindElement(By.Name("PermissionValues[4].SpecificStores[0]"));
var option = dropdown.FindElement(By.TagName("option"));
var storeId = option.GetAttribute("value");
option.Click();

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

@ -3,6 +3,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ExchangeSharp;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server.Features;
@ -62,9 +63,9 @@ namespace BTCPayServer.Tests
semaphore.Dispose();
}
public async Task<HttpContext> GetNextRequest()
public async Task<HttpContext> GetNextRequest(CancellationToken cancellationToken = default)
{
return await _channel.Reader.ReadAsync();
return await _channel.Reader.ReadAsync(cancellationToken);
}
}
}

View file

@ -16,6 +16,7 @@ using BTCPayServer.Tests.Logging;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NBitcoin;
using NBitcoin.OpenAsset;
using NBitpayClient;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
@ -149,12 +150,12 @@ namespace BTCPayServer.Tests
var user1 = await unauthClient.CreateUser(
new CreateApplicationUserRequest() { Email = "test@gmail.com", Password = "abceudhqw" });
Assert.Empty(user1.Roles);
// We have no admin, so it should work
var user2 = await unauthClient.CreateUser(
new CreateApplicationUserRequest() { Email = "test2@gmail.com", Password = "abceudhqw" });
Assert.Empty(user2.Roles);
// Duplicate email
await AssertValidationError(new[] { "Email" },
async () => await unauthClient.CreateUser(
@ -170,7 +171,7 @@ namespace BTCPayServer.Tests
Assert.Contains("ServerAdmin", admin.Roles);
Assert.NotNull(admin.Created);
Assert.True((DateTimeOffset.Now - admin.Created).Value.Seconds < 10);
// Creating a new user without proper creds is now impossible (unauthorized)
// Because if registration are locked and that an admin exists, we don't accept unauthenticated connection
await AssertHttpError(401,
@ -611,6 +612,101 @@ namespace BTCPayServer.Tests
}
}
[Fact(Timeout = TestTimeout)]
[Trait("Integration", "Integration")]
public async Task CanUseWebhooks()
{
void AssertHook(FakeServer fakeServer, Client.Models.StoreWebhookData hook)
{
Assert.True(hook.Enabled);
Assert.True(hook.AuthorizedEvents.Everything);
Assert.False(hook.AutomaticRedelivery);
Assert.Equal(fakeServer.ServerUri.AbsoluteUri, hook.Url);
}
using var tester = ServerTester.Create();
using var fakeServer = new FakeServer();
await fakeServer.Start();
await tester.StartAsync();
var user = tester.NewAccount();
user.GrantAccess();
user.RegisterDerivationScheme("BTC");
var clientProfile = await user.CreateClient(Policies.CanModifyStoreWebhooks, Policies.CanCreateInvoice);
var hook = await clientProfile.CreateWebhook(user.StoreId, new CreateStoreWebhookRequest()
{
Url = fakeServer.ServerUri.AbsoluteUri,
AutomaticRedelivery = false
});
Assert.NotNull(hook.Secret);
AssertHook(fakeServer, hook);
hook = await clientProfile.GetWebhook(user.StoreId, hook.Id);
AssertHook(fakeServer, hook);
var hooks = await clientProfile.GetWebhooks(user.StoreId);
hook = Assert.Single(hooks);
AssertHook(fakeServer, hook);
await clientProfile.CreateInvoice(user.StoreId,
new CreateInvoiceRequest() { Currency = "USD", Amount = 100 });
var req = await fakeServer.GetNextRequest();
req.Response.StatusCode = 200;
fakeServer.Done();
hook = await clientProfile.UpdateWebhook(user.StoreId, hook.Id, new UpdateStoreWebhookRequest()
{
Url = hook.Url,
Secret = "lol",
AutomaticRedelivery = false
});
Assert.Null(hook.Secret);
AssertHook(fakeServer, hook);
var deliveries = await clientProfile.GetWebhookDeliveries(user.StoreId, hook.Id);
var delivery = Assert.Single(deliveries);
delivery = await clientProfile.GetWebhookDelivery(user.StoreId, hook.Id, delivery.Id);
Assert.NotNull(delivery);
Assert.Equal(WebhookDeliveryStatus.HttpSuccess, delivery.Status);
var newDeliveryId = await clientProfile.RedeliverWebhook(user.StoreId, hook.Id, delivery.Id);
req = await fakeServer.GetNextRequest();
req.Response.StatusCode = 404;
fakeServer.Done();
await TestUtils.EventuallyAsync(async () =>
{
var newDelivery = await clientProfile.GetWebhookDelivery(user.StoreId, hook.Id, newDeliveryId);
Assert.NotNull(newDelivery);
Assert.Equal(404, newDelivery.HttpCode);
var req = await clientProfile.GetWebhookDeliveryRequest(user.StoreId, hook.Id, newDeliveryId);
Assert.Equal(delivery.Id, req.OrignalDeliveryId);
Assert.True(req.IsRedelivery);
Assert.Equal(WebhookDeliveryStatus.HttpError, newDelivery.Status);
});
deliveries = await clientProfile.GetWebhookDeliveries(user.StoreId, hook.Id);
Assert.Equal(2, deliveries.Length);
Assert.Equal(newDeliveryId, deliveries[0].Id);
var jObj = await clientProfile.GetWebhookDeliveryRequest(user.StoreId, hook.Id, newDeliveryId);
Assert.NotNull(jObj);
Logs.Tester.LogInformation("Should not be able to access webhook without proper auth");
var unauthorized = await user.CreateClient(Policies.CanCreateInvoice);
await AssertHttpError(403, async () =>
{
await unauthorized.GetWebhookDeliveryRequest(user.StoreId, hook.Id, newDeliveryId);
});
Logs.Tester.LogInformation("Can use btcpay.store.canmodifystoresettings to query webhooks");
clientProfile = await user.CreateClient(Policies.CanModifyStoreSettings, Policies.CanCreateInvoice);
await clientProfile.GetWebhookDeliveryRequest(user.StoreId, hook.Id, newDeliveryId);
Logs.Tester.LogInformation("Testing corner cases");
Assert.Null(await clientProfile.GetWebhookDeliveryRequest(user.StoreId, "lol", newDeliveryId));
Assert.Null(await clientProfile.GetWebhookDeliveryRequest(user.StoreId, hook.Id, "lol"));
Assert.Null(await clientProfile.GetWebhookDeliveryRequest(user.StoreId, "lol", "lol"));
Assert.Null(await clientProfile.GetWebhook(user.StoreId, "lol"));
await AssertHttpError(404, async () =>
{
await clientProfile.UpdateWebhook(user.StoreId, "lol", new UpdateStoreWebhookRequest() { Url = hook.Url });
});
Assert.True(await clientProfile.DeleteWebhook(user.StoreId, hook.Id));
Assert.False(await clientProfile.DeleteWebhook(user.StoreId, hook.Id));
}
[Fact(Timeout = TestTimeout)]
[Trait("Integration", "Integration")]
public async Task HealthControllerTests()
@ -821,6 +917,7 @@ namespace BTCPayServer.Tests
var user = tester.NewAccount();
await user.GrantAccessAsync();
await user.MakeAdmin();
await user.SetupWebhook();
var client = await user.CreateClient(Policies.Unrestricted);
var viewOnly = await user.CreateClient(Policies.CanViewInvoices);
@ -878,10 +975,43 @@ namespace BTCPayServer.Tests
await client.UnarchiveInvoice(user.StoreId, invoice.Id);
Assert.NotNull(await client.GetInvoice(user.StoreId, invoice.Id));
foreach (var marked in new[] { InvoiceStatus.Complete, InvoiceStatus.Invalid })
{
var inv = await client.CreateInvoice(user.StoreId,
new CreateInvoiceRequest() { Currency = "USD", Amount = 100 });
await user.PayInvoice(inv.Id);
await client.MarkInvoiceStatus(user.StoreId, inv.Id, new MarkInvoiceStatusRequest()
{
Status = marked
});
var result = await client.GetInvoice(user.StoreId, inv.Id);
if (marked == InvoiceStatus.Complete)
{
Assert.Equal(InvoiceStatus.Complete, result.Status);
user.AssertHasWebhookEvent<WebhookInvoiceConfirmedEvent>(WebhookEventType.InvoiceConfirmed,
o =>
{
Assert.Equal(inv.Id, o.InvoiceId);
Assert.True(o.ManuallyMarked);
});
}
if (marked == InvoiceStatus.Invalid)
{
Assert.Equal(InvoiceStatus.Invalid, result.Status);
var evt = user.AssertHasWebhookEvent<WebhookInvoiceInvalidEvent>(WebhookEventType.InvoiceInvalid,
o =>
{
Assert.Equal(inv.Id, o.InvoiceId);
Assert.True(o.ManuallyMarked);
});
Assert.NotNull(await client.GetWebhookDelivery(evt.StoreId, evt.WebhookId, evt.DeliveryId));
}
}
}
}
[Fact(Timeout = 60 * 2 * 1000)]
[Fact(Timeout = 60 * 2 * 1000)]
[Trait("Integration", "Integration")]
[Trait("Lightning", "Lightning")]
public async Task CanUseLightningAPI()
@ -907,7 +1037,7 @@ namespace BTCPayServer.Tests
var info = await client.GetLightningNodeInfo("BTC");
Assert.Single(info.NodeURIs);
Assert.NotEqual(0, info.BlockHeight);
var err = await Assert.ThrowsAsync<HttpRequestException>(async () => await client.GetLightningNodeChannels("BTC"));
Assert.Contains("503", err.Message);
// Not permission for the store!

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 FakeServer server = new FakeServer();
await server.Start();
s.Driver.FindElement(By.Name("PayloadUrl")).Clear();
s.Driver.FindElement(By.Name("PayloadUrl")).SendKeys(server.ServerUri.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.ServerUri.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.Request.Headers;
var actualSig = headers["BTCPay-Sig"].First();
var bytes = await request.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.Response.StatusCode = 200;
server.Done();
Logs.Tester.LogInformation("Let's make a failed event");
s.CreateInvoice(store.storeName);
request = await server.GetNextRequest();
request.Response.StatusCode = 404;
server.Done();
// 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.Response.StatusCode = 404;
server.Done();
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.Response.StatusCode = 404;
server.Done();
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

@ -23,6 +23,7 @@ namespace BTCPayServer.Tests
return new ServerTester(scope, newDb);
}
public List<IDisposable> Resources = new List<IDisposable>();
readonly string _Directory;
public ServerTester(string scope, bool newDb)
{
@ -145,7 +146,7 @@ namespace BTCPayServer.Tests
var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
var sub = PayTester.GetService<EventAggregator>().Subscribe<T>(evt =>
{
if(correctEvent is null)
if (correctEvent is null)
tcs.TrySetResult(evt);
else if (correctEvent(evt))
{
@ -207,6 +208,8 @@ namespace BTCPayServer.Tests
public void Dispose()
{
foreach (var r in this.Resources)
r.Dispose();
Logs.Tester.LogInformation("Disposing the BTCPayTester...");
foreach (var store in Stores)
{

View file

@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
@ -23,8 +25,10 @@ using NBitcoin.Payment;
using NBitpayClient;
using NBXplorer.DerivationStrategy;
using NBXplorer.Models;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Xunit;
using Xunit.Sdk;
namespace BTCPayServer.Tests
{
@ -427,5 +431,86 @@ namespace BTCPayServer.Tests
return null;
return parsedBip21;
}
class WebhookListener : IDisposable
{
private Client.Models.StoreWebhookData _wh;
private FakeServer _server;
private readonly List<WebhookInvoiceEvent> _webhookEvents;
private CancellationTokenSource _cts;
public WebhookListener(Client.Models.StoreWebhookData wh, FakeServer server, List<WebhookInvoiceEvent> webhookEvents)
{
_wh = wh;
_server = server;
_webhookEvents = webhookEvents;
_cts = new CancellationTokenSource();
_ = Listen(_cts.Token);
}
async Task Listen(CancellationToken cancellation)
{
while (!cancellation.IsCancellationRequested)
{
var req = await _server.GetNextRequest(cancellation);
var bytes = await req.Request.Body.ReadBytesAsync((int)req.Request.Headers.ContentLength);
var callback = Encoding.UTF8.GetString(bytes);
_webhookEvents.Add(JsonConvert.DeserializeObject<WebhookInvoiceEvent>(callback));
req.Response.StatusCode = 200;
_server.Done();
}
}
public void Dispose()
{
_cts.Cancel();
_server.Dispose();
}
}
public List<WebhookInvoiceEvent> WebhookEvents { get; set; } = new List<WebhookInvoiceEvent>();
public TEvent AssertHasWebhookEvent<TEvent>(WebhookEventType eventType, Action<TEvent> assert) where TEvent : class
{
foreach (var evt in WebhookEvents)
{
if (evt.Type == eventType)
{
var typedEvt = evt.ReadAs<TEvent>();
try
{
assert(typedEvt);
return typedEvt;
}
catch (XunitException ex)
{
}
}
}
Assert.True(false, "No webhook event match the assertion");
return null;
}
public async Task SetupWebhook()
{
FakeServer server = new FakeServer();
await server.Start();
var client = await CreateClient(Policies.CanModifyStoreWebhooks);
var wh = await client.CreateWebhook(StoreId, new CreateStoreWebhookRequest()
{
AutomaticRedelivery = false,
Url = server.ServerUri.AbsoluteUri
});
parent.Resources.Add(new WebhookListener(wh, server, WebhookEvents));
}
public async Task PayInvoice(string invoiceId)
{
var inv = await BitPay.GetInvoiceAsync(invoiceId);
var net = parent.ExplorerNode.Network;
this.parent.ExplorerNode.SendToAddress(BitcoinAddress.Create(inv.BitcoinAddress, net), inv.BtcDue);
await TestUtils.EventuallyAsync(async () =>
{
var localInvoice = await BitPay.GetInvoiceAsync(invoiceId, Facade.Merchant);
Assert.Equal("paid", localInvoice.Status);
});
}
}
}

View file

@ -1000,7 +1000,6 @@ namespace BTCPayServer.Tests
}
}
}
var invoice2 = acc.BitPay.GetInvoice(invoice.Id);
Assert.NotNull(invoice2);
}
@ -2581,6 +2580,7 @@ namespace BTCPayServer.Tests
var user = tester.NewAccount();
user.GrantAccess();
user.RegisterDerivationScheme("BTC");
await user.SetupWebhook();
var invoice = user.BitPay.CreateInvoice(
new Invoice()
{
@ -2637,7 +2637,6 @@ namespace BTCPayServer.Tests
var cashCow = tester.ExplorerNode;
var invoiceAddress = BitcoinAddress.Create(invoice.BitcoinAddress, cashCow.Network);
var iii = ctx.AddressInvoices.ToArray();
Assert.True(IsMapped(invoice, ctx));
cashCow.SendToAddress(invoiceAddress, firstPayment);
@ -2741,6 +2740,23 @@ namespace BTCPayServer.Tests
Assert.Equal(Money.Zero, localInvoice.BtcDue);
Assert.Equal("paidOver", (string)((JValue)localInvoice.ExceptionStatus).Value);
});
// Test on the webhooks
user.AssertHasWebhookEvent<WebhookInvoiceConfirmedEvent>(WebhookEventType.InvoiceConfirmed,
c =>
{
Assert.False(c.ManuallyMarked);
});
user.AssertHasWebhookEvent<WebhookInvoicePaidEvent>(WebhookEventType.InvoicePaidInFull,
c =>
{
Assert.True(c.OverPaid);
});
user.AssertHasWebhookEvent<WebhookInvoiceReceivedPaymentEvent>(WebhookEventType.InvoiceReceivedPayment,
c =>
{
Assert.False(c.AfterExpiration);
});
}
}

View file

@ -251,5 +251,5 @@
<_ContentIncludedByDefault Remove="Views\Components\NotificationsDropdown\Default.cshtml" />
</ItemGroup>
<ProjectExtensions><VisualStudio><UserProperties wwwroot_4swagger_4v1_4swagger_1template_1invoices_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1pull-payments_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1serverinfo_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1stores_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" /></VisualStudio></ProjectExtensions>
<ProjectExtensions><VisualStudio><UserProperties wwwroot_4swagger_4v1_4swagger_1template_1invoices_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1pull-payments_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1serverinfo_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1stores_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1users_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1webhooks_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" /></VisualStudio></ProjectExtensions>
</Project>

View file

@ -0,0 +1,198 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
using Amazon.Runtime;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Security;
using BTCPayServer.Services.Stores;
using Google.Apis.Auth.OAuth2;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json.Linq;
using Org.BouncyCastle.Bcpg.OpenPgp;
namespace BTCPayServer.Controllers.GreenField
{
[ApiController]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield,
Policy = Policies.CanModifyStoreWebhooks)]
[EnableCors(CorsPolicies.All)]
public class StoreWebhooksController : ControllerBase
{
public StoreWebhooksController(StoreRepository storeRepository, WebhookNotificationManager webhookNotificationManager)
{
StoreRepository = storeRepository;
WebhookNotificationManager = webhookNotificationManager;
}
public StoreRepository StoreRepository { get; }
public WebhookNotificationManager WebhookNotificationManager { get; }
[HttpGet("~/api/v1/stores/{storeId}/webhooks/{webhookId?}")]
public async Task<IActionResult> ListWebhooks(string storeId, string webhookId)
{
if (webhookId is null)
{
var store = HttpContext.GetStoreData();
if (store == null)
return NotFound();
return Ok((await StoreRepository.GetWebhooks(storeId))
.Select(o => FromModel(o, false))
.ToList());
}
else
{
var w = await StoreRepository.GetWebhook(storeId, webhookId);
if (w is null)
return NotFound();
return Ok(FromModel(w, false));
}
}
[HttpPost("~/api/v1/stores/{storeId}/webhooks")]
public async Task<IActionResult> CreateWebhook(string storeId, Client.Models.CreateStoreWebhookRequest create)
{
var store = HttpContext.GetStoreData();
if (store == null)
return NotFound();
ValidateWebhookRequest(create);
if (!ModelState.IsValid)
return this.CreateValidationError(ModelState);
var webhookId = await StoreRepository.CreateWebhook(storeId, ToModel(create));
var w = await StoreRepository.GetWebhook(storeId, webhookId);
if (w is null)
return NotFound();
return Ok(FromModel(w, true));
}
private void ValidateWebhookRequest(StoreWebhookBaseData create)
{
if (!Uri.TryCreate(create?.Url, UriKind.Absolute, out var uri))
ModelState.AddModelError(nameof(Url), "Invalid Url");
}
[HttpPut("~/api/v1/stores/{storeId}/webhooks/{webhookId}")]
public async Task<IActionResult> UpdateWebhook(string storeId, string webhookId, Client.Models.UpdateStoreWebhookRequest update)
{
ValidateWebhookRequest(update);
if (!ModelState.IsValid)
return this.CreateValidationError(ModelState);
var store = HttpContext.GetStoreData();
if (store == null)
return NotFound();
var w = await StoreRepository.GetWebhook(storeId, webhookId);
if (w is null)
return NotFound();
await StoreRepository.UpdateWebhook(storeId, webhookId, ToModel(update));
return await ListWebhooks(storeId, webhookId);
}
[HttpDelete("~/api/v1/stores/{storeId}/webhooks/{webhookId}")]
public async Task<IActionResult> DeleteWebhook(string storeId, string webhookId)
{
var store = HttpContext.GetStoreData();
if (store == null)
return NotFound();
var w = await StoreRepository.GetWebhook(storeId, webhookId);
if (w is null)
return NotFound();
await StoreRepository.DeleteWebhook(storeId, webhookId);
return Ok();
}
private WebhookBlob ToModel(StoreWebhookBaseData create)
{
return new WebhookBlob()
{
Active = create.Enabled,
Url = create.Url,
Secret = create.Secret,
AuthorizedEvents = create.AuthorizedEvents is Client.Models.StoreWebhookBaseData.AuthorizedEventsData aed ?
new AuthorizedWebhookEvents()
{
Everything = aed.Everything,
SpecificEvents = aed.SpecificEvents
}:
new AuthorizedWebhookEvents() { Everything = true },
AutomaticRedelivery = create.AutomaticRedelivery,
};
}
[HttpGet("~/api/v1/stores/{storeId}/webhooks/{webhookId}/deliveries/{deliveryId?}")]
public async Task<IActionResult> ListDeliveries(string storeId, string webhookId, string deliveryId, int? count = null)
{
if (deliveryId is null)
{
var store = HttpContext.GetStoreData();
if (store == null)
return NotFound();
return Ok((await StoreRepository.GetWebhookDeliveries(storeId, webhookId, count))
.Select(o => FromModel(o))
.ToList());
}
else
{
var delivery = await StoreRepository.GetWebhookDelivery(storeId, webhookId, deliveryId);
if (delivery is null)
return NotFound();
return Ok(FromModel(delivery));
}
}
[HttpPost("~/api/v1/stores/{storeId}/webhooks/{webhookId}/deliveries/{deliveryId}/redeliver")]
public async Task<IActionResult> RedeliverWebhook(string storeId, string webhookId, string deliveryId)
{
var delivery = await StoreRepository.GetWebhookDelivery(HttpContext.GetStoreData().Id, webhookId, deliveryId);
if (delivery is null)
return NotFound();
return this.Ok(new JValue(await WebhookNotificationManager.Redeliver(deliveryId)));
}
[HttpGet("~/api/v1/stores/{storeId}/webhooks/{webhookId}/deliveries/{deliveryId}/request")]
public async Task<IActionResult> GetDeliveryRequest(string storeId, string webhookId, string deliveryId)
{
var store = HttpContext.GetStoreData();
if (store == null)
return NotFound();
var delivery = await StoreRepository.GetWebhookDelivery(storeId, webhookId, deliveryId);
if (delivery is null)
return NotFound();
return File(delivery.GetBlob().Request, "application/json");
}
private Client.Models.WebhookDeliveryData FromModel(Data.WebhookDeliveryData data)
{
var b = data.GetBlob();
return new Client.Models.WebhookDeliveryData()
{
Id = data.Id,
Timestamp = data.Timestamp,
Status = b.Status,
ErrorMessage = b.ErrorMessage,
HttpCode = b.HttpCode
};
}
Client.Models.StoreWebhookData FromModel(Data.WebhookData data, bool includeSecret)
{
var b = data.GetBlob();
return new Client.Models.StoreWebhookData()
{
Id = data.Id,
Url = b.Url,
Enabled = b.Active,
Secret = includeSecret ? b.Secret : null,
AutomaticRedelivery = b.AutomaticRedelivery,
AuthorizedEvents = new Client.Models.StoreWebhookData.AuthorizedEventsData()
{
Everything = b.AuthorizedEvents.Everything,
SpecificEvents = b.AuthorizedEvents.SpecificEvents
}
};
}
}
}

View file

@ -51,7 +51,7 @@ namespace BTCPayServer.Controllers
}
[HttpGet]
[Route("invoices")]
public async Task<DataWrapper<InvoiceResponse[]>> GetInvoices(
public async Task<IActionResult> GetInvoices(
string token,
DateTimeOffset? dateStart = null,
DateTimeOffset? dateEnd = null,
@ -61,6 +61,8 @@ namespace BTCPayServer.Controllers
int? limit = null,
int? offset = null)
{
if (User.Identity.AuthenticationType == Security.Bitpay.BitpayAuthenticationTypes.Anonymous)
return Forbid(Security.Bitpay.BitpayAuthenticationTypes.Anonymous);
if (dateEnd != null)
dateEnd = dateEnd.Value + TimeSpan.FromDays(1); //Should include the end day
@ -79,7 +81,7 @@ namespace BTCPayServer.Controllers
var entities = (await _InvoiceRepository.GetInvoices(query))
.Select((o) => o.EntityToDTO()).ToArray();
return DataWrapper.Create(entities);
return Json(DataWrapper.Create(entities));
}
}
}

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

@ -465,6 +465,8 @@ namespace BTCPayServer.Controllers
{BTCPayServer.Client.Policies.CanCreateUser, ("Create new users", "The app will be able to create new users on this server.")},
{BTCPayServer.Client.Policies.CanModifyStoreSettings, ("Modify your stores", "The app will be able to view, modify, delete and create new invoices on all your stores.")},
{$"{BTCPayServer.Client.Policies.CanModifyStoreSettings}:", ("Manage selected stores", "The app will be able to view, modify, delete and create new invoices on the selected stores.")},
{BTCPayServer.Client.Policies.CanModifyStoreWebhooks, ("Modify stores webhooks", "The app will be mofidy the webhooks of all your stores.")},
{$"{BTCPayServer.Client.Policies.CanModifyStoreWebhooks}:", ("Modify selected stores' webhooks", "The app will be mofidy the webhooks of the selected stores.")},
{BTCPayServer.Client.Policies.CanViewStoreSettings, ("View your stores", "The app will be able to view stores settings.")},
{$"{BTCPayServer.Client.Policies.CanViewStoreSettings}:", ("View your stores", "The app will be able to view the selected stores' settings.")},
{BTCPayServer.Client.Policies.CanModifyServerSettings, ("Manage your server", "The app will have total control on the server settings of your server")},

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;
@ -791,6 +793,7 @@ namespace BTCPayServer.Controllers
}
public string GeneratedPairingCode { get; set; }
public WebhookNotificationManager WebhookNotificationManager { get; }
[HttpGet]
[Route("{storeId}/Tokens/Create")]

View file

@ -1,3 +1,4 @@
using System.Runtime.InteropServices;
using BTCPayServer.Services.Invoices;
using Newtonsoft.Json.Linq;

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

@ -38,7 +38,7 @@ namespace BTCPayServer.Events
{ReceivedPayment, InvoiceEventCode.ReceivedPayment},
{PaidInFull, InvoiceEventCode.PaidInFull},
{Expired, InvoiceEventCode.Expired},
{Confirmed, InvoiceEventCode.Completed},
{Confirmed, InvoiceEventCode.Confirmed},
{Completed, InvoiceEventCode.Completed},
{MarkedInvalid, InvoiceEventCode.MarkedInvalid},
{FailedToConfirm, InvoiceEventCode.FailedToConfirm},

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,320 @@
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 = WebhookEvent.DefaultSerializerSettings;
}
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 Data.WebhookDeliveryData Delivery;
public WebhookBlob WebhookBlob;
public string WebhookId;
public WebhookDeliveryRequest(string webhookId, WebhookEvent webhookEvent, Data.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.WebhookId = webhookDelivery.Webhook.Id;
// if we redelivered a redelivery, we still want the initial delivery here
webhookEvent.OrignalDeliveryId ??= deliveryId;
webhookEvent.IsRedelivery = true;
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) is WebhookInvoiceEvent webhookEvent))
continue;
if (!ShouldDeliver(webhookEvent.Type, webhookBlob))
continue;
Data.WebhookDeliveryData delivery = NewDelivery();
delivery.WebhookId = webhook.Id;
webhookEvent.InvoiceId = invoiceEvent.InvoiceId;
webhookEvent.StoreId = invoiceEvent.Invoice.StoreId;
webhookEvent.DeliveryId = delivery.Id;
webhookEvent.WebhookId = webhook.Id;
webhookEvent.OrignalDeliveryId = delivery.Id;
webhookEvent.IsRedelivery = false;
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 WebhookInvoiceEvent GetWebhookEvent(InvoiceEvent invoiceEvent)
{
var eventCode = invoiceEvent.EventCode;
switch (eventCode)
{
case InvoiceEventCode.Completed:
return null;
case InvoiceEventCode.Confirmed:
case InvoiceEventCode.MarkedCompleted:
return new WebhookInvoiceConfirmedEvent(WebhookEventType.InvoiceConfirmed)
{
ManuallyMarked = eventCode == InvoiceEventCode.MarkedCompleted
};
case InvoiceEventCode.Created:
return new WebhookInvoiceEvent(WebhookEventType.InvoiceCreated);
case InvoiceEventCode.Expired:
case InvoiceEventCode.ExpiredPaidPartial:
return new WebhookInvoiceExpiredEvent(WebhookEventType.InvoiceExpired)
{
PartiallyPaid = eventCode == InvoiceEventCode.ExpiredPaidPartial
};
case InvoiceEventCode.FailedToConfirm:
case InvoiceEventCode.MarkedInvalid:
return new WebhookInvoiceInvalidEvent(WebhookEventType.InvoiceInvalid)
{
ManuallyMarked = eventCode == InvoiceEventCode.MarkedInvalid
};
case InvoiceEventCode.PaidInFull:
case InvoiceEventCode.PaidAfterExpiration:
return new WebhookInvoicePaidEvent(WebhookEventType.InvoicePaidInFull)
{
OverPaid = invoiceEvent.Invoice.ExceptionStatus == InvoiceExceptionStatus.PaidOver,
PaidAfterExpiration = eventCode == InvoiceEventCode.PaidAfterExpiration
};
case InvoiceEventCode.ReceivedPayment:
return new WebhookInvoiceReceivedPaymentEvent(WebhookEventType.InvoiceReceivedPayment)
{
AfterExpiration = invoiceEvent.Invoice.Status == InvoiceStatus.Expired || invoiceEvent.Invoice.Status == InvoiceStatus.Invalid
};
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 Data.WebhookDeliveryData NewDelivery()
{
var delivery = new Data.WebhookDeliveryData();
delivery.Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(16));
delivery.Timestamp = DateTimeOffset.UtcNow;
return delivery;
}
}
}

View file

@ -217,7 +217,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(Data.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

@ -19,6 +19,9 @@ namespace BTCPayServer.Payments.PayJoin
services.AddHttpClient(PayjoinClient.PayjoinOnionNamedClient)
.ConfigureHttpClient(h => h.DefaultRequestHeaders.ConnectionClose = true)
.ConfigurePrimaryHttpMessageHandler<Socks5HttpClientHandler>();
services.AddHttpClient(WebhookNotificationManager.OnionNamedClient)
.ConfigureHttpClient(h => h.DefaultRequestHeaders.ConnectionClose = true)
.ConfigurePrimaryHttpMessageHandler<Socks5HttpClientHandler>();
}
}
}

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<Data.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<Data.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,112 @@ 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();
IQueryable<WebhookDeliveryData> req = ctx.StoreWebhooks
.Where(s => s.StoreId == storeId && s.WebhookId == webhookId)
.SelectMany(s => s.Webhook.Deliveries)
.OrderByDescending(s => s.Timestamp);
if (count is int c)
req = req.Take(c);
return await req
.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));
if (string.IsNullOrEmpty(blob.Secret))
blob.Secret = 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 +334,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

@ -1,4 +1,4 @@
@{
@{
Layout = null;
}
<!DOCTYPE html>
@ -23,6 +23,6 @@
</head>
<body>
<redoc spec-url="@Url.ActionLink("Swagger")"></redoc>
<script src="https://cdn.jsdelivr.net/npm/redoc@2.0.0-rc.24/bundles/redoc.standalone.js" integrity="sha384-ZO+OTQZMsYIcoraCBa8iJW/5b2O8K1ujHmRfOwSvpVBlHUeKq5t3/kh1p8JQJ99X" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/redoc@2.0.0-rc.45/bundles/redoc.standalone.js" integrity="sha384-RC31+q3tyqdcilXYaU++ii/FAByqeZ+sjKUHMJ8hMzIY5k4kzNqi4Ett88EZ/4lq" crossorigin="anonymous"></script>
</body>
</html>

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);

View file

@ -53,7 +53,7 @@
"securitySchemes": {
"API Key": {
"type": "apiKey",
"description": "BTCPay Server supports authenticating and authorizing users through an API Key that is generated by them. Send the API Key as a header value to Authorization with the format: `token {token}`. For a smoother experience, you can generate a url that redirects users to an API key creation screen.\n\n The following permissions applies to the context of the user creating the API Key:\n * `unrestricted`: Allow unrestricted access to your account.\n * `btcpay.server.canmodifyserversettings`: Allow total control on the server settings. (only if user is administrator)\n * `btcpay.server.cancreateuser`: Allow the creation of new users on this server. (only if user is an administrator)\n * `btcpay.user.canviewprofile`: Allow view access to your user profile.\n * `btcpay.user.canmodifyprofile`: Allow view and modification access to your user profile.\n\nThe following permissions applies to all stores of the user, you can limit to a specific store with the following format: `btcpay.store.cancreateinvoice:6HSHAEU4iYWtjxtyRs9KyPjM9GAQp8kw2T9VWbGG1FnZ`:\n * `btcpay.store.canviewstoresettings`: Allow view access to the stores settings. \n * `btcpay.store.canmodifystoresettings`: Allow view and modification access to the stores settings.\n * `btcpay.store.cancreateinvoice`: Allow invoice creation of the store.\n\nNote that API Keys only limits permission of a user and can never expand it. If an API Key has the permission `btcpay.server.canmodifyserversettings` but that the user account creating this API Key is not administrator, the API Key will not be able to modify the server settings.\n",
"description": "BTCPay Server supports authenticating and authorizing users through an API Key that is generated by them. Send the API Key as a header value to Authorization with the format: `token {token}`. For a smoother experience, you can generate a url that redirects users to an API key creation screen.\n\n The following permissions applies to the context of the user creating the API Key:\n * `unrestricted`: Allow unrestricted access to your account.\n * `btcpay.server.canmodifyserversettings`: Allow total control on the server settings. (only if user is administrator)\n * `btcpay.server.cancreateuser`: Allow the creation of new users on this server. (only if user is an administrator)\n * `btcpay.user.canviewprofile`: Allow view access to your user profile.\n * `btcpay.user.canmodifyprofile`: Allow view and modification access to your user profile.\n\nThe following permissions applies to all stores of the user, you can limit to a specific store with the following format: `btcpay.store.cancreateinvoice:6HSHAEU4iYWtjxtyRs9KyPjM9GAQp8kw2T9VWbGG1FnZ`:\n * `btcpay.store.canviewstoresettings`: Allow view access to the stores settings. \n * `btcpay.store.webhooks.canmodifywebhooks`: Allow modifications of webhooks in the store. \n * `btcpay.store.canmodifystoresettings`: Allow view and modification access to the stores settings and webhooks.\n * `btcpay.store.cancreateinvoice`: Allow invoice creation of the store.\n\nNote that API Keys only limits permission of a user and can never expand it. If an API Key has the permission `btcpay.server.canmodifyserversettings` but that the user account creating this API Key is not administrator, the API Key will not be able to modify the server settings.\n",
"name": "Authorization",
"in": "header",
"scheme": "token"

View file

@ -0,0 +1,931 @@
{
"paths": {
"/api/v1/stores/{storeId}/webhooks": {
"parameters": [
{
"name": "storeId",
"in": "path",
"required": true,
"description": "The store id",
"schema": {
"type": "string"
}
}
],
"get": {
"tags": [
"Webhooks"
],
"summary": "Get webhooks of a store",
"description": "View webhooks of a store",
"operationId": "Webhokks_GetWebhooks",
"responses": {
"200": {
"description": "List of webhooks",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WebhookDataList"
}
}
}
}
},
"security": [
{
"API Key": [
"btcpay.store.webhooks.canmodifywebhooks"
],
"Basic": []
}
]
},
"post": {
"tags": [ "Webhooks" ],
"summary": "Create a new webhook",
"description": "Create a new webhook",
"requestBody": {
"x-name": "request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WebhookDataCreate"
}
}
},
"required": true,
"x-position": 1
},
"responses": {
"200": {
"description": "Information about the new webhook",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WebhookDataCreate"
}
}
}
},
"400": {
"description": "A list of errors that occurred when creating the webhook",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidationProblemDetails"
}
}
}
}
},
"security": [
{
"API Key": [
"btcpay.store.webhooks.canmodifywebhooks"
],
"Basic": []
}
]
}
},
"/api/v1/stores/{storeId}/webhooks/{webhookId}": {
"parameters": [
{
"name": "storeId",
"in": "path",
"required": true,
"description": "The store id",
"schema": {
"type": "string"
}
},
{
"name": "webhookId",
"in": "path",
"required": true,
"description": "The webhook id",
"schema": {
"type": "string"
}
}
],
"get": {
"tags": [
"Webhooks"
],
"summary": "Get a webhook of a store",
"description": "View webhook of a store",
"operationId": "Webhokks_GetWebhook",
"responses": {
"200": {
"description": "A webhook",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WebhookData"
}
}
}
},
"404": {
"description": "The webhook has not been found"
}
},
"security": [
{
"API Key": [
"btcpay.store.webhooks.canmodifywebhooks"
],
"Basic": []
}
]
},
"put": {
"tags": [ "Webhooks" ],
"summary": "Update a webhook",
"description": "Update a webhook",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WebhookDataBase"
}
}
},
"required": true,
"x-position": 1
},
"responses": {
"200": {
"description": "Information about the updated webhook",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WebhookData"
}
}
}
},
"400": {
"description": "A list of errors that occurred when creating the webhook",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidationProblemDetails"
}
}
}
}
},
"security": [
{
"API Key": [
"btcpay.store.webhooks.canmodifywebhooks"
],
"Basic": []
}
]
},
"delete": {
"tags": [ "Webhooks" ],
"summary": "Delete a webhook",
"description": "Delete a webhook",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WebhookDataBase"
}
}
},
"required": true,
"x-position": 1
},
"responses": {
"200": {
"description": "The webhook has been deleted"
},
"404": {
"description": "The webhook does not exist"
}
},
"security": [
{
"API Key": [
"btcpay.store.webhooks.canmodifywebhooks"
],
"Basic": []
}
]
}
},
"/api/v1/stores/{storeId}/webhooks/{webhookId}/deliveries": {
"parameters": [
{
"name": "storeId",
"in": "path",
"required": true,
"description": "The store id",
"schema": {
"type": "string"
}
},
{
"name": "webhookId",
"in": "path",
"required": true,
"description": "The webhook id",
"schema": {
"type": "string"
}
}
],
"get": {
"tags": [
"Webhooks"
],
"summary": "Get latest deliveries",
"description": "List the latest deliveries to the webhook, ordered from the most recent",
"parameters": [
{
"name": "count",
"in": "query",
"description": "The number of latest deliveries to fetch",
"required": false,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "List of deliveries",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WebhookDeliveryList"
}
}
}
}
},
"security": [
{
"API Key": [
"btcpay.store.webhooks.canmodifywebhooks"
],
"Basic": []
}
]
}
},
"/api/v1/stores/{storeId}/webhooks/{webhookId}/deliveries/{deliveryId}": {
"parameters": [
{
"name": "storeId",
"in": "path",
"required": true,
"description": "The store id",
"schema": {
"type": "string"
}
},
{
"name": "webhookId",
"in": "path",
"required": true,
"description": "The webhook id",
"schema": {
"type": "string"
}
},
{
"name": "deliveryId",
"in": "path",
"required": true,
"description": "The id of the delivery",
"schema": {
"type": "string"
}
}
],
"get": {
"tags": [
"Webhooks"
],
"summary": "Get a webhook delivery",
"description": "Information about a webhook delivery",
"responses": {
"200": {
"description": "Information about a delivery",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WebhookDeliveryData"
}
}
}
},
"404": {
"description": "The delivery does not exists."
}
},
"security": [
{
"API Key": [
"btcpay.store.webhooks.canmodifywebhooks"
],
"Basic": []
}
]
}
},
"/api/v1/stores/{storeId}/webhooks/{webhookId}/deliveries/{deliveryId}/request": {
"parameters": [
{
"name": "storeId",
"in": "path",
"required": true,
"description": "The store id",
"schema": {
"type": "string"
}
},
{
"name": "webhookId",
"in": "path",
"required": true,
"description": "The webhook id",
"schema": {
"type": "string"
}
},
{
"name": "deliveryId",
"in": "path",
"required": true,
"description": "The id of the delivery",
"schema": {
"type": "string"
}
}
],
"get": {
"tags": [
"Webhooks"
],
"summary": "Get the delivery's request",
"description": "The delivery's JSON request sent to the endpoint",
"responses": {
"200": {
"description": "The delivery's JSON Request",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
},
"404": {
"description": "The delivery does not exists."
}
},
"security": [
{
"API Key": [
"btcpay.store.webhooks.canmodifywebhooks"
],
"Basic": []
}
]
}
},
"/api/v1/stores/{storeId}/webhooks/{webhookId}/deliveries/{deliveryId}/redeliver": {
"parameters": [
{
"name": "storeId",
"in": "path",
"required": true,
"description": "The store id",
"schema": {
"type": "string"
}
},
{
"name": "webhookId",
"in": "path",
"required": true,
"description": "The webhook id",
"schema": {
"type": "string"
}
},
{
"name": "deliveryId",
"in": "path",
"required": true,
"description": "The id of the delivery",
"schema": {
"type": "string"
}
}
],
"post": {
"tags": [
"Webhooks"
],
"summary": "Redeliver the delivery",
"description": "Redeliver the delivery",
"responses": {
"200": {
"description": "The new delivery id being broadcasted. (Broadcast happen asynchronously with this call)",
"content": {
"application/json": {
"schema": {
"type": "string"
}
}
}
},
"404": {
"description": "The delivery does not exists."
}
},
"security": [
{
"API Key": [
"btcpay.store.webhooks.canmodifywebhooks"
],
"Basic": []
}
]
}
}
},
"components": {
"schemas": {
"WebhookDeliveryList": {
"type": "array",
"items": {
"$ref": "#/components/schemas/WebhookDeliveryData"
}
},
"WebhookDeliveryData": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "The id of the delivery",
"nullable": false
},
"timestamp": {
"type": "number",
"format": "int64",
"nullable": false,
"description": "Timestamp of when the delivery got broadcasted"
},
"httpCode": {
"type": "number",
"description": "HTTP code received by the remote service, if any.",
"nullable": true
},
"errorMessage": {
"type": "string",
"description": "User friendly error message, if any."
},
"status": {
"type": "string",
"description": "Whether the delivery failed or not (possible values are: `Failed`, `HttpError`, `HttpSuccess`)"
}
}
},
"WebhookDataList": {
"type": "array",
"items": {
"$ref": "#/components/schemas/WebhookData"
}
},
"WebhookData": {
"allOf": [
{
"$ref": "#/components/schemas/WebhookDataBase"
},
{
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "The id of the webhook",
"nullable": false
}
}
}
]
},
"WebhookDataCreate": {
"allOf": [
{
"$ref": "#/components/schemas/WebhookData"
},
{
"type": "object",
"properties": {
"secret": {
"type": "string",
"description": "Must be used by the callback receiver to ensure the delivery comes from BTCPay Server. BTCPay Server includes the `BTCPay-Sig` HTTP header, whose format is `sha256=HMAC256(UTF8(webhook's secret), body)`. The pattern to authenticate the webhook is similar to [how to secure webhooks in Github](https://developer.github.com/webhooks/securing/) but with sha256 instead of sha1.",
"nullable": false
}
}
}
]
},
"WebhookDataBase": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"description": "The id of the webhook",
"nullable": false
},
"enabled": {
"type": "boolean",
"description": "Whether this webhook is enabled or not",
"nullable": false,
"default": true
},
"automaticRedelivery": {
"type": "boolean",
"description": "If true, BTCPay Server will retry to redeliver any failed delivery after 10 seconds, 1 minutes and up to 6 times after 10 minutes.",
"nullable": false,
"default": true
},
"url": {
"type": "string",
"description": "The endpoint where BTCPay Server will make the POST request with the webhook body",
"nullable": false
},
"authorizedEvents": {
"type": "object",
"description": "Which event should be received by this endpoint",
"properties": {
"everything": {
"type": "string",
"description": "If true, the endpoint will receive all events related to the store.",
"nullable": false,
"default": true
},
"specificEvents": {
"type": "string",
"description": "If `everything` is false, the specific events that the endpoint is interested in. Current events are: `InvoiceCreated`, `InvoiceReceivedPayment`, `InvoicePaidInFull`, `InvoiceExpired`, `InvoiceConfirmed`, `InvoiceInvalid`.",
"nullable": false
}
}
}
}
},
"WebhookEvent": {
"type": "object",
"additionalProperties": false,
"properties": {
"deliveryId": {
"type": "string",
"nullable": false,
"description": "The delivery id of the webhook"
},
"webhookId": {
"type": "string",
"nullable": false,
"description": "The id of the webhook"
},
"originalDeliveryId": {
"type": "string",
"nullable": false,
"description": "If this delivery is a redelivery, the is the delivery id of the original delivery."
},
"isRedelivery": {
"type": "boolean",
"nullable": false,
"description": "True if this delivery is a redelivery"
},
"type": {
"type": "string",
"nullable": false,
"description": "The type of this event, current available are `InvoiceCreated`, `InvoiceReceivedPayment`, `InvoicePaidInFull`, `InvoiceExpired`, `InvoiceConfirmed`, and `InvoiceInvalid`."
},
"timestamp": {
"type": "number",
"format": "int64",
"description": "The timestamp when this delivery has been created"
}
}
},
"WebhookInvoiceEvent": {
"allOf": [
{
"$ref": "#/components/schemas/WebhookEvent"
},
{
"type": "object",
"properties": {
"storeId": {
"type": "string",
"description": "The store id of the invoice's event",
"nullable": false
},
"invoiceId": {
"type": "string",
"description": "The invoice id of the invoice's event",
"nullable": false
}
}
}
]
},
"WebhookInvoiceConfirmedEvent": {
"description": "Callback sent if the `type` is `InvoiceConfirmed`",
"allOf": [
{
"$ref": "#/components/schemas/WebhookInvoiceEvent"
},
{
"type": "object",
"properties": {
"manuallyMarked": {
"type": "boolean",
"description": "Whether the invoice have been manually marked as confirmed",
"nullable": false
}
}
}
]
},
"WebhookInvoiceInvalidEvent": {
"description": "Callback sent if the `type` is `InvoiceInvalid`",
"allOf": [
{
"$ref": "#/components/schemas/WebhookInvoiceEvent"
},
{
"type": "object",
"properties": {
"manuallyMarked": {
"type": "boolean",
"description": "Whether the invoice have been manually marked as confirmed. If false, this invoice has received payments which could not confirm in time.",
"nullable": false
}
}
}
]
},
"WebhookInvoicePaidEvent": {
"description": "Callback sent if the `type` is `InvoicePaidInFull`",
"allOf": [
{
"$ref": "#/components/schemas/WebhookInvoiceEvent"
},
{
"type": "object",
"properties": {
"overPaid": {
"type": "boolean",
"description": "Whether this invoice has received more money than expected",
"nullable": false
},
"paidAfterExpiration": {
"type": "boolean",
"description": "Whether this invoice has been paid too late",
"nullable": false
}
}
}
]
},
"WebhookInvoiceReceivedPaymentEvent": {
"description": "Callback sent if the `type` is `InvoiceReceivedPayment`",
"allOf": [
{
"$ref": "#/components/schemas/WebhookInvoiceEvent"
},
{
"type": "object",
"properties": {
"afterExpiration": {
"type": "boolean",
"description": "Whether this payment has been sent after expiration of the invoice",
"nullable": false
}
}
}
]
},
"WebhookInvoiceExpiredEvent": {
"description": "Callback sent if the `type` is `InvoiceExpired`",
"allOf": [
{
"$ref": "#/components/schemas/WebhookInvoiceEvent"
},
{
"type": "object",
"properties": {
"partiallyPaid": {
"type": "boolean",
"description": "Whether the invoice received some payments before being expired.",
"nullable": false
}
}
}
]
}
}
},
"x-webhooks": {
"Invoice created": {
"post": {
"summary": "Invoice created",
"description": "A new invoice has been created",
"parameters": [
{
"in": "header",
"name": "BTCPay-Sig",
"required": true,
"description": "The HMAC of the body's byte with the secret's of the webhook. `sha256=HMAC256(UTF8(webhook's secret), body)`",
"schema": {
"type": "string",
"example": "sha256=b438519edde5c8144a4f9bcec51a9d346eca6506887c2ceeae1c0092884a97b9"
}
}
],
"tags": [
"Webhooks"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WebhookInvoiceEvent"
}
}
}
}
}
},
"Invoice expired": {
"post": {
"summary": "Invoice expired",
"description": "An invoice expired",
"parameters": [
{
"in": "header",
"name": "BTCPay-Sig",
"required": true,
"description": "The HMAC of the body's byte with the secret's of the webhook. `sha256=HMAC256(UTF8(webhook's secret), body)`",
"schema": {
"type": "string",
"example": "sha256=b438519edde5c8144a4f9bcec51a9d346eca6506887c2ceeae1c0092884a97b9"
}
}
],
"tags": [
"Webhooks"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WebhookInvoiceExpiredEvent"
}
}
}
}
}
},
"Payment received": {
"post": {
"summary": "Payment received",
"description": "An invoice received a payment",
"parameters": [
{
"in": "header",
"name": "BTCPay-Sig",
"required": true,
"description": "The HMAC of the body's byte with the secret's of the webhook. `sha256=HMAC256(UTF8(webhook's secret), body)`",
"schema": {
"type": "string",
"example": "sha256=b438519edde5c8144a4f9bcec51a9d346eca6506887c2ceeae1c0092884a97b9"
}
}
],
"tags": [
"Webhooks"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WebhookInvoiceReceivedPaymentEvent"
}
}
}
}
}
},
"Invoice paid": {
"post": {
"summary": "Invoice paid",
"description": "An invoice has been fully paid",
"parameters": [
{
"in": "header",
"name": "BTCPay-Sig",
"required": true,
"description": "The HMAC of the body's byte with the secret's of the webhook. `sha256=HMAC256(UTF8(webhook's secret), body)`",
"schema": {
"type": "string",
"example": "sha256=b438519edde5c8144a4f9bcec51a9d346eca6506887c2ceeae1c0092884a97b9"
}
}
],
"tags": [
"Webhooks"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WebhookInvoicePaidEvent"
}
}
}
}
}
},
"Invoice invalid": {
"post": {
"summary": "Invoice invalid",
"description": "An invoice became invalid",
"parameters": [
{
"in": "header",
"name": "BTCPay-Sig",
"required": true,
"description": "The HMAC of the body's byte with the secret's of the webhook. `sha256=HMAC256(UTF8(webhook's secret), body)`",
"schema": {
"type": "string",
"example": "sha256=b438519edde5c8144a4f9bcec51a9d346eca6506887c2ceeae1c0092884a97b9"
}
}
],
"tags": [
"Webhooks"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WebhookInvoiceInvalidEvent"
}
}
}
}
}
},
"Invoice confirmed": {
"post": {
"summary": "Invoice confirmed",
"description": "An invoice has been confirmed, order considered settled",
"parameters": [
{
"in": "header",
"name": "BTCPay-Sig",
"required": true,
"description": "The HMAC of the body's byte with the secret's of the webhook. `sha256=HMAC256(UTF8(webhook's secret), body)`",
"schema": {
"type": "string",
"example": "sha256=b438519edde5c8144a4f9bcec51a9d346eca6506887c2ceeae1c0092884a97b9"
}
}
],
"tags": [
"Webhooks"
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WebhookInvoiceConfirmedEvent"
}
}
}
}
}
}
},
"tags": [
{
"name": "Webhooks"
}
]
}