mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-02-20 13:34:37 +01:00
Merge pull request #2058 from NicolasDorier/webhook2
Add Webhooks in store's settings
This commit is contained in:
commit
9c5fd1b478
59 changed files with 3288 additions and 48 deletions
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BTCPayServer.Client
|
||||
{
|
||||
|
|
65
BTCPayServer.Client/BTCPayServerClient.Webhooks.cs
Normal file
65
BTCPayServer.Client/BTCPayServerClient.Webhooks.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -9,4 +9,4 @@ namespace BTCPayServer.Client.Models
|
|||
Complete,
|
||||
Confirmed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
35
BTCPayServer.Client/Models/StoreWebhookData.cs
Normal file
35
BTCPayServer.Client/Models/StoreWebhookData.cs
Normal 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; }
|
||||
}
|
||||
}
|
18
BTCPayServer.Client/Models/WebhookDeliveryData.cs
Normal file
18
BTCPayServer.Client/Models/WebhookDeliveryData.cs
Normal 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; }
|
||||
}
|
||||
}
|
13
BTCPayServer.Client/Models/WebhookDeliveryStatus.cs
Normal file
13
BTCPayServer.Client/Models/WebhookDeliveryStatus.cs
Normal file
|
@ -0,0 +1,13 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace BTCPayServer.Client.Models
|
||||
{
|
||||
public enum WebhookDeliveryStatus
|
||||
{
|
||||
Failed,
|
||||
HttpError,
|
||||
HttpSuccess
|
||||
}
|
||||
}
|
36
BTCPayServer.Client/Models/WebhookEvent.cs
Normal file
36
BTCPayServer.Client/Models/WebhookEvent.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
16
BTCPayServer.Client/Models/WebhookEventType.cs
Normal file
16
BTCPayServer.Client/Models/WebhookEventType.cs
Normal 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
|
||||
}
|
||||
}
|
86
BTCPayServer.Client/Models/WebhookInvoiceEvent.cs
Normal file
86
BTCPayServer.Client/Models/WebhookInvoiceEvent.cs
Normal 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; }
|
||||
}
|
||||
}
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
26
BTCPayServer.Data/Data/InvoiceWebhookDeliveryData.cs
Normal file
26
BTCPayServer.Data/Data/InvoiceWebhookDeliveryData.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
30
BTCPayServer.Data/Data/StoreWebhookData.cs
Normal file
30
BTCPayServer.Data/Data/StoreWebhookData.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
22
BTCPayServer.Data/Data/WebhookData.cs
Normal file
22
BTCPayServer.Data/Data/WebhookData.cs
Normal 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; }
|
||||
}
|
||||
}
|
36
BTCPayServer.Data/Data/WebhookDeliveryData.cs
Normal file
36
BTCPayServer.Data/Data/WebhookDeliveryData.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
115
BTCPayServer.Data/Migrations/20201108054749_webhooks.cs
Normal file
115
BTCPayServer.Data/Migrations/20201108054749_webhooks.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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!
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
{
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
198
BTCPayServer/Controllers/GreenField/StoreWebhooksController.cs
Normal file
198
BTCPayServer/Controllers/GreenField/StoreWebhooksController.cs
Normal 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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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")},
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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")]
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
using System.Runtime.InteropServices;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
|
|
66
BTCPayServer/Data/WebhookDataExtensions.cs
Normal file
66
BTCPayServer/Data/WebhookDataExtensions.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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},
|
||||
|
|
|
@ -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>();
|
||||
|
|
320
BTCPayServer/HostedServices/WebhookNotificationManager.cs
Normal file
320
BTCPayServer/HostedServices/WebhookNotificationManager.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>());
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
80
BTCPayServer/Models/StoreViewModels/EditWebhookViewModel.cs
Normal file
80
BTCPayServer/Models/StoreViewModels/EditWebhookViewModel.cs
Normal 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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
17
BTCPayServer/Models/StoreViewModels/WebhooksViewModel.cs
Normal file
17
BTCPayServer/Models/StoreViewModels/WebhooksViewModel.cs
Normal 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; }
|
||||
}
|
||||
}
|
|
@ -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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
157
BTCPayServer/Views/Stores/ModifyWebhook.cshtml
Normal file
157
BTCPayServer/Views/Stores/ModifyWebhook.cshtml
Normal 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>
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
46
BTCPayServer/Views/Stores/Webhooks.cshtml
Normal file
46
BTCPayServer/Views/Stores/Webhooks.cshtml
Normal 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")
|
||||
}
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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"
|
||||
|
|
931
BTCPayServer/wwwroot/swagger/v1/swagger.template.webhooks.json
Normal file
931
BTCPayServer/wwwroot/swagger/v1/swagger.template.webhooks.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
Loading…
Add table
Reference in a new issue