diff --git a/BTCPayServer.Client/BTCPayServerClient.Authorization.cs b/BTCPayServer.Client/BTCPayServerClient.Authorization.cs index c59775935..e5e5ab852 100644 --- a/BTCPayServer.Client/BTCPayServerClient.Authorization.cs +++ b/BTCPayServer.Client/BTCPayServerClient.Authorization.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Threading.Tasks; namespace BTCPayServer.Client { diff --git a/BTCPayServer.Client/BTCPayServerClient.Webhooks.cs b/BTCPayServer.Client/BTCPayServerClient.Webhooks.cs new file mode 100644 index 000000000..a34309fd7 --- /dev/null +++ b/BTCPayServer.Client/BTCPayServerClient.Webhooks.cs @@ -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 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(response); + } + public async Task 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(response); + } + public async Task 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(response); + } + public async Task 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 GetWebhooks(string storeId, CancellationToken token = default) + { + var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/webhooks"), token); + return await HandleResponse(response); + } + public async Task 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(response); + } + public async Task 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(response); + } + public async Task 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(response); + } + + public async Task 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(response); + } + } +} diff --git a/BTCPayServer.Client/BTCPayServerClient.cs b/BTCPayServer.Client/BTCPayServerClient.cs index d410fdf5c..651a7dd89 100644 --- a/BTCPayServer.Client/BTCPayServerClient.cs +++ b/BTCPayServer.Client/BTCPayServerClient.cs @@ -65,7 +65,8 @@ namespace BTCPayServer.Client protected async Task HandleResponse(HttpResponseMessage message) { await HandleResponse(message); - return JsonConvert.DeserializeObject(await message.Content.ReadAsStringAsync()); + var str = await message.Content.ReadAsStringAsync(); + return JsonConvert.DeserializeObject(str); } protected virtual HttpRequestMessage CreateHttpRequest(string path, diff --git a/BTCPayServer.Client/Models/InvoiceStatus.cs b/BTCPayServer.Client/Models/InvoiceStatus.cs index a3d866acd..b30e4e73a 100644 --- a/BTCPayServer.Client/Models/InvoiceStatus.cs +++ b/BTCPayServer.Client/Models/InvoiceStatus.cs @@ -9,4 +9,4 @@ namespace BTCPayServer.Client.Models Complete, Confirmed } -} \ No newline at end of file +} diff --git a/BTCPayServer.Client/Models/StoreWebhookData.cs b/BTCPayServer.Client/Models/StoreWebhookData.cs new file mode 100644 index 000000000..497bd8fd3 --- /dev/null +++ b/BTCPayServer.Client/Models/StoreWebhookData.cs @@ -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(); + } + + 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; } + } +} diff --git a/BTCPayServer.Client/Models/WebhookDeliveryData.cs b/BTCPayServer.Client/Models/WebhookDeliveryData.cs new file mode 100644 index 000000000..0c3626253 --- /dev/null +++ b/BTCPayServer.Client/Models/WebhookDeliveryData.cs @@ -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; } + } +} diff --git a/BTCPayServer.Client/Models/WebhookDeliveryStatus.cs b/BTCPayServer.Client/Models/WebhookDeliveryStatus.cs new file mode 100644 index 000000000..dde4b52d4 --- /dev/null +++ b/BTCPayServer.Client/Models/WebhookDeliveryStatus.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace BTCPayServer.Client.Models +{ + public enum WebhookDeliveryStatus + { + Failed, + HttpError, + HttpSuccess + } +} diff --git a/BTCPayServer.Client/Models/WebhookEvent.cs b/BTCPayServer.Client/Models/WebhookEvent.cs new file mode 100644 index 000000000..b117919f8 --- /dev/null +++ b/BTCPayServer.Client/Models/WebhookEvent.cs @@ -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 AdditionalData { get; set; } + public T ReadAs() + { + var str = JsonConvert.SerializeObject(this, DefaultSerializerSettings); + return JsonConvert.DeserializeObject(str, DefaultSerializerSettings); + } + } +} diff --git a/BTCPayServer.Client/Models/WebhookEventType.cs b/BTCPayServer.Client/Models/WebhookEventType.cs new file mode 100644 index 000000000..a0644bf80 --- /dev/null +++ b/BTCPayServer.Client/Models/WebhookEventType.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace BTCPayServer.Client.Models +{ + public enum WebhookEventType + { + InvoiceCreated, + InvoiceReceivedPayment, + InvoicePaidInFull, + InvoiceExpired, + InvoiceConfirmed, + InvoiceInvalid + } +} diff --git a/BTCPayServer.Client/Models/WebhookInvoiceEvent.cs b/BTCPayServer.Client/Models/WebhookInvoiceEvent.cs new file mode 100644 index 000000000..027caa056 --- /dev/null +++ b/BTCPayServer.Client/Models/WebhookInvoiceEvent.cs @@ -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; } + } +} diff --git a/BTCPayServer.Client/Permissions.cs b/BTCPayServer.Client/Permissions.cs index 9d939cea3..245325723 100644 --- a/BTCPayServer.Client/Permissions.cs +++ b/BTCPayServer.Client/Permissions.cs @@ -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: diff --git a/BTCPayServer.Data/Data/ApplicationDbContext.cs b/BTCPayServer.Data/Data/ApplicationDbContext.cs index 0e18da7de..fe857548a 100644 --- a/BTCPayServer.Data/Data/ApplicationDbContext.cs +++ b/BTCPayServer.Data/Data/ApplicationDbContext.cs @@ -63,6 +63,11 @@ namespace BTCPayServer.Data public DbSet U2FDevices { get; set; } public DbSet Notifications { get; set; } + public DbSet StoreWebhooks { get; set; } + public DbSet Webhooks { get; set; } + public DbSet WebhookDeliveries { get; set; } + public DbSet InvoiceWebhookDeliveries { get; set; } + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { var isConfigured = optionsBuilder.Options.Extensions.OfType().Any(); @@ -73,6 +78,7 @@ namespace BTCPayServer.Data protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); + Data.UserStore.OnModelCreating(builder); NotificationData.OnModelCreating(builder); InvoiceData.OnModelCreating(builder); PaymentData.OnModelCreating(builder); @@ -91,7 +97,11 @@ namespace BTCPayServer.Data PayoutData.OnModelCreating(builder); RefundData.OnModelCreating(builder); U2FDevice.OnModelCreating(builder); - + + Data.WebhookDeliveryData.OnModelCreating(builder); + Data.StoreWebhookData.OnModelCreating(builder); + Data.InvoiceWebhookDeliveryData.OnModelCreating(builder); + if (Database.IsSqlite() && !_designTime) { // SQLite does not have proper support for DateTimeOffset via Entity Framework Core, see the limitations diff --git a/BTCPayServer.Data/Data/InvoiceWebhookDeliveryData.cs b/BTCPayServer.Data/Data/InvoiceWebhookDeliveryData.cs new file mode 100644 index 000000000..186f02148 --- /dev/null +++ b/BTCPayServer.Data/Data/InvoiceWebhookDeliveryData.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Microsoft.EntityFrameworkCore; + +namespace BTCPayServer.Data +{ + public class InvoiceWebhookDeliveryData + { + public string InvoiceId { get; set; } + public InvoiceData Invoice { get; set; } + public string DeliveryId { get; set; } + public WebhookDeliveryData Delivery { get; set; } + internal static void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .HasKey(p => new { p.InvoiceId, p.DeliveryId }); + builder.Entity() + .HasOne(o => o.Invoice) + .WithOne().OnDelete(DeleteBehavior.Cascade); + builder.Entity() + .HasOne(o => o.Delivery) + .WithOne().OnDelete(DeleteBehavior.Cascade); + } + } +} diff --git a/BTCPayServer.Data/Data/StoreWebhookData.cs b/BTCPayServer.Data/Data/StoreWebhookData.cs new file mode 100644 index 000000000..02ebbe2af --- /dev/null +++ b/BTCPayServer.Data/Data/StoreWebhookData.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Microsoft.EntityFrameworkCore; +using System.Linq; + +namespace BTCPayServer.Data +{ + public class StoreWebhookData + { + public string StoreId { get; set; } + public string WebhookId { get; set; } + public WebhookData Webhook { get; set; } + public StoreData Store { get; set; } + + internal static void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .HasKey(p => new { p.StoreId, p.WebhookId }); + + builder.Entity() + .HasOne(o => o.Webhook) + .WithOne().OnDelete(DeleteBehavior.Cascade); + + builder.Entity() + .HasOne(o => o.Store) + .WithOne().OnDelete(DeleteBehavior.Cascade); + } + } +} diff --git a/BTCPayServer.Data/Data/WebhookData.cs b/BTCPayServer.Data/Data/WebhookData.cs new file mode 100644 index 000000000..ca5b7fd36 --- /dev/null +++ b/BTCPayServer.Data/Data/WebhookData.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Text; +using Microsoft.EntityFrameworkCore; + +namespace BTCPayServer.Data +{ + public class WebhookData + { + [Key] + [MaxLength(25)] + public string Id + { + get; + set; + } + [Required] + public byte[] Blob { get; set; } + public List Deliveries { get; set; } + } +} diff --git a/BTCPayServer.Data/Data/WebhookDeliveryData.cs b/BTCPayServer.Data/Data/WebhookDeliveryData.cs new file mode 100644 index 000000000..3d74c3763 --- /dev/null +++ b/BTCPayServer.Data/Data/WebhookDeliveryData.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text; +using Microsoft.EntityFrameworkCore; + +namespace BTCPayServer.Data +{ + public class WebhookDeliveryData + { + [Key] + [MaxLength(25)] + public string Id { get; set; } + [MaxLength(25)] + [Required] + public string WebhookId { get; set; } + public WebhookData Webhook { get; set; } + + [Required] + public DateTimeOffset Timestamp + { + get; set; + } + + [Required] + public byte[] Blob { get; set; } + internal static void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .HasOne(o => o.Webhook) + .WithMany(a => a.Deliveries).OnDelete(DeleteBehavior.Cascade); + builder.Entity().HasIndex(o => o.WebhookId); + } + } +} diff --git a/BTCPayServer.Data/Migrations/20201108054749_webhooks.cs b/BTCPayServer.Data/Migrations/20201108054749_webhooks.cs new file mode 100644 index 000000000..fcf1d6739 --- /dev/null +++ b/BTCPayServer.Data/Migrations/20201108054749_webhooks.cs @@ -0,0 +1,115 @@ +using System; +using BTCPayServer.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace BTCPayServer.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20201108054749_webhooks")] + public partial class webhooks : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Webhooks", + columns: table => new + { + Id = table.Column(maxLength: 25, nullable: false), + Blob = table.Column(nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Webhooks", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "StoreWebhooks", + columns: table => new + { + StoreId = table.Column(nullable: false), + WebhookId = table.Column(nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_StoreWebhooks", x => new { x.StoreId, x.WebhookId }); + table.ForeignKey( + name: "FK_StoreWebhooks_Stores_StoreId", + column: x => x.StoreId, + principalTable: "Stores", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_StoreWebhooks_Webhooks_WebhookId", + column: x => x.WebhookId, + principalTable: "Webhooks", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "WebhookDeliveries", + columns: table => new + { + Id = table.Column(maxLength: 25, nullable: false), + WebhookId = table.Column(maxLength: 25, nullable: false), + Timestamp = table.Column(nullable: false), + Blob = table.Column(nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_WebhookDeliveries", x => x.Id); + table.ForeignKey( + name: "FK_WebhookDeliveries_Webhooks_WebhookId", + column: x => x.WebhookId, + principalTable: "Webhooks", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "InvoiceWebhookDeliveries", + columns: table => new + { + InvoiceId = table.Column(nullable: false), + DeliveryId = table.Column(nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_InvoiceWebhookDeliveries", x => new { x.InvoiceId, x.DeliveryId }); + table.ForeignKey( + name: "FK_InvoiceWebhookDeliveries_WebhookDeliveries_DeliveryId", + column: x => x.DeliveryId, + principalTable: "WebhookDeliveries", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_InvoiceWebhookDeliveries_Invoices_InvoiceId", + column: x => x.InvoiceId, + principalTable: "Invoices", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_WebhookDeliveries_WebhookId", + table: "WebhookDeliveries", + column: "WebhookId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "InvoiceWebhookDeliveries"); + + migrationBuilder.DropTable( + name: "StoreWebhooks"); + + migrationBuilder.DropTable( + name: "WebhookDeliveries"); + + migrationBuilder.DropTable( + name: "Webhooks"); + } + } +} diff --git a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs index db897fb04..ff64ecef3 100644 --- a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -257,6 +257,25 @@ namespace BTCPayServer.Migrations b.ToTable("InvoiceEvents"); }); + modelBuilder.Entity("BTCPayServer.Data.InvoiceWebhookDeliveryData", b => + { + b.Property("InvoiceId") + .HasColumnType("TEXT"); + + b.Property("DeliveryId") + .HasColumnType("TEXT"); + + b.HasKey("InvoiceId", "DeliveryId"); + + b.HasIndex("DeliveryId") + .IsUnique(); + + b.HasIndex("InvoiceId") + .IsUnique(); + + b.ToTable("InvoiceWebhookDeliveries"); + }); + modelBuilder.Entity("BTCPayServer.Data.NotificationData", b => { b.Property("Id") @@ -588,6 +607,25 @@ namespace BTCPayServer.Migrations b.ToTable("Stores"); }); + modelBuilder.Entity("BTCPayServer.Data.StoreWebhookData", b => + { + b.Property("StoreId") + .HasColumnType("TEXT"); + + b.Property("WebhookId") + .HasColumnType("TEXT"); + + b.HasKey("StoreId", "WebhookId"); + + b.HasIndex("StoreId") + .IsUnique(); + + b.HasIndex("WebhookId") + .IsUnique(); + + b.ToTable("StoreWebhooks"); + }); + modelBuilder.Entity("BTCPayServer.Data.StoredFile", b => { b.Property("Id") @@ -696,6 +734,46 @@ namespace BTCPayServer.Migrations b.ToTable("WalletTransactions"); }); + modelBuilder.Entity("BTCPayServer.Data.WebhookData", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasMaxLength(25); + + b.Property("Blob") + .IsRequired() + .HasColumnType("BLOB"); + + b.HasKey("Id"); + + b.ToTable("Webhooks"); + }); + + modelBuilder.Entity("BTCPayServer.Data.WebhookDeliveryData", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasMaxLength(25); + + b.Property("Blob") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("WebhookId") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(25); + + b.HasKey("Id"); + + b.HasIndex("WebhookId"); + + b.ToTable("WebhookDeliveries"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => { b.Property("Id") @@ -883,6 +961,21 @@ namespace BTCPayServer.Migrations .IsRequired(); }); + modelBuilder.Entity("BTCPayServer.Data.InvoiceWebhookDeliveryData", b => + { + b.HasOne("BTCPayServer.Data.WebhookDeliveryData", "Delivery") + .WithOne() + .HasForeignKey("BTCPayServer.Data.InvoiceWebhookDeliveryData", "DeliveryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BTCPayServer.Data.InvoiceData", "Invoice") + .WithOne() + .HasForeignKey("BTCPayServer.Data.InvoiceWebhookDeliveryData", "InvoiceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("BTCPayServer.Data.NotificationData", b => { b.HasOne("BTCPayServer.Data.ApplicationUser", "ApplicationUser") @@ -956,6 +1049,21 @@ namespace BTCPayServer.Migrations .IsRequired(); }); + modelBuilder.Entity("BTCPayServer.Data.StoreWebhookData", b => + { + b.HasOne("BTCPayServer.Data.StoreData", "Store") + .WithOne() + .HasForeignKey("BTCPayServer.Data.StoreWebhookData", "StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BTCPayServer.Data.WebhookData", "Webhook") + .WithOne() + .HasForeignKey("BTCPayServer.Data.StoreWebhookData", "WebhookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("BTCPayServer.Data.StoredFile", b => { b.HasOne("BTCPayServer.Data.ApplicationUser", "ApplicationUser") @@ -995,6 +1103,15 @@ namespace BTCPayServer.Migrations .IsRequired(); }); + modelBuilder.Entity("BTCPayServer.Data.WebhookDeliveryData", b => + { + b.HasOne("BTCPayServer.Data.WebhookData", "Webhook") + .WithMany("Deliveries") + .HasForeignKey("WebhookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) diff --git a/BTCPayServer.Tests/ApiKeysTests.cs b/BTCPayServer.Tests/ApiKeysTests.cs index a286f6243..99d5536c7 100644 --- a/BTCPayServer.Tests/ApiKeysTests.cs +++ b/BTCPayServer.Tests/ApiKeysTests.cs @@ -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(); diff --git a/BTCPayServer.Tests/BTCPayServer.Tests.csproj b/BTCPayServer.Tests/BTCPayServer.Tests.csproj index d6ab7faa4..ecf82d0fb 100644 --- a/BTCPayServer.Tests/BTCPayServer.Tests.csproj +++ b/BTCPayServer.Tests/BTCPayServer.Tests.csproj @@ -21,6 +21,7 @@ + diff --git a/BTCPayServer.Tests/FakeServer.cs b/BTCPayServer.Tests/FakeServer.cs index 44062c07d..a9692a2d7 100644 --- a/BTCPayServer.Tests/FakeServer.cs +++ b/BTCPayServer.Tests/FakeServer.cs @@ -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 GetNextRequest() + public async Task GetNextRequest(CancellationToken cancellationToken = default) { - return await _channel.Reader.ReadAsync(); + return await _channel.Reader.ReadAsync(cancellationToken); } } } diff --git a/BTCPayServer.Tests/GreenfieldAPITests.cs b/BTCPayServer.Tests/GreenfieldAPITests.cs index 62f73fab6..696e16ccc 100644 --- a/BTCPayServer.Tests/GreenfieldAPITests.cs +++ b/BTCPayServer.Tests/GreenfieldAPITests.cs @@ -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(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(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(async () => await client.GetLightningNodeChannels("BTC")); Assert.Contains("503", err.Message); // Not permission for the store! diff --git a/BTCPayServer.Tests/SeleniumTester.cs b/BTCPayServer.Tests/SeleniumTester.cs index 4f7f6faca..d9f87f405 100644 --- a/BTCPayServer.Tests/SeleniumTester.cs +++ b/BTCPayServer.Tests/SeleniumTester.cs @@ -318,10 +318,9 @@ namespace BTCPayServer.Tests Driver.FindElement(By.Name("StoreId")).SendKeys(storeName); Driver.FindElement(By.Id("Create")).Click(); - Assert.True(Driver.PageSource.Contains("just created!"), "Unable to create Invoice"); + AssertHappyMessage(); var statusElement = Driver.FindElement(By.ClassName("alert-success")); var id = statusElement.Text.Split(" ")[1]; - return id; } diff --git a/BTCPayServer.Tests/SeleniumTests.cs b/BTCPayServer.Tests/SeleniumTests.cs index accfef6e4..3f22bc605 100644 --- a/BTCPayServer.Tests/SeleniumTests.cs +++ b/BTCPayServer.Tests/SeleniumTests.cs @@ -1,10 +1,12 @@ using System; using System.Globalization; using System.Linq; +using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using BTCPayServer.Abstractions.Models; +using BTCPayServer.Client.Models; using BTCPayServer.Data; using BTCPayServer.Models; using BTCPayServer.Services.Wallets; @@ -13,11 +15,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 { @@ -134,19 +143,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(); @@ -596,6 +606,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() diff --git a/BTCPayServer.Tests/ServerTester.cs b/BTCPayServer.Tests/ServerTester.cs index 541c1a4e2..3e72f9ba5 100644 --- a/BTCPayServer.Tests/ServerTester.cs +++ b/BTCPayServer.Tests/ServerTester.cs @@ -23,6 +23,7 @@ namespace BTCPayServer.Tests return new ServerTester(scope, newDb); } + public List Resources = new List(); readonly string _Directory; public ServerTester(string scope, bool newDb) { @@ -145,7 +146,7 @@ namespace BTCPayServer.Tests var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var sub = PayTester.GetService().Subscribe(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) { diff --git a/BTCPayServer.Tests/TestAccount.cs b/BTCPayServer.Tests/TestAccount.cs index 7b3c1d9d8..af00dd0a6 100644 --- a/BTCPayServer.Tests/TestAccount.cs +++ b/BTCPayServer.Tests/TestAccount.cs @@ -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 _webhookEvents; + private CancellationTokenSource _cts; + public WebhookListener(Client.Models.StoreWebhookData wh, FakeServer server, List 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(callback)); + req.Response.StatusCode = 200; + _server.Done(); + } + } + public void Dispose() + { + _cts.Cancel(); + _server.Dispose(); + } + } + + public List WebhookEvents { get; set; } = new List(); + public TEvent AssertHasWebhookEvent(WebhookEventType eventType, Action assert) where TEvent : class + { + foreach (var evt in WebhookEvents) + { + if (evt.Type == eventType) + { + var typedEvt = evt.ReadAs(); + 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); + }); + } } } diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index 067881c41..58ccf93a0 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -1001,7 +1001,6 @@ namespace BTCPayServer.Tests } } } - var invoice2 = acc.BitPay.GetInvoice(invoice.Id); Assert.NotNull(invoice2); } @@ -2582,6 +2581,7 @@ namespace BTCPayServer.Tests var user = tester.NewAccount(); user.GrantAccess(); user.RegisterDerivationScheme("BTC"); + await user.SetupWebhook(); var invoice = user.BitPay.CreateInvoice( new Invoice() { @@ -2638,7 +2638,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); @@ -2742,6 +2741,23 @@ namespace BTCPayServer.Tests Assert.Equal(Money.Zero, localInvoice.BtcDue); Assert.Equal("paidOver", (string)((JValue)localInvoice.ExceptionStatus).Value); }); + + // Test on the webhooks + user.AssertHasWebhookEvent(WebhookEventType.InvoiceConfirmed, + c => + { + Assert.False(c.ManuallyMarked); + }); + user.AssertHasWebhookEvent(WebhookEventType.InvoicePaidInFull, + c => + { + Assert.True(c.OverPaid); + }); + user.AssertHasWebhookEvent(WebhookEventType.InvoiceReceivedPayment, + c => + { + Assert.False(c.AfterExpiration); + }); } } diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index fc1a654ac..a098f076d 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -251,5 +251,5 @@ <_ContentIncludedByDefault Remove="Views\Components\NotificationsDropdown\Default.cshtml" /> - + diff --git a/BTCPayServer/Controllers/GreenField/StoreWebhooksController.cs b/BTCPayServer/Controllers/GreenField/StoreWebhooksController.cs new file mode 100644 index 000000000..e2ab101b5 --- /dev/null +++ b/BTCPayServer/Controllers/GreenField/StoreWebhooksController.cs @@ -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 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 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 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 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 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 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 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 + } + }; + } + } +} diff --git a/BTCPayServer/Controllers/InvoiceController.API.cs b/BTCPayServer/Controllers/InvoiceController.API.cs index 85f9216be..4d6db5029 100644 --- a/BTCPayServer/Controllers/InvoiceController.API.cs +++ b/BTCPayServer/Controllers/InvoiceController.API.cs @@ -52,7 +52,7 @@ namespace BTCPayServer.Controllers } [HttpGet] [Route("invoices")] - public async Task> GetInvoices( + public async Task GetInvoices( string token, DateTimeOffset? dateStart = null, DateTimeOffset? dateEnd = null, @@ -62,6 +62,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 @@ -80,7 +82,7 @@ namespace BTCPayServer.Controllers var entities = (await _InvoiceRepository.GetInvoices(query)) .Select((o) => o.EntityToDTO()).ToArray(); - return DataWrapper.Create(entities); + return Json(DataWrapper.Create(entities)); } } } diff --git a/BTCPayServer/Controllers/InvoiceController.UI.cs b/BTCPayServer/Controllers/InvoiceController.UI.cs index 48bbb7e76..79c18077b 100644 --- a/BTCPayServer/Controllers/InvoiceController.UI.cs +++ b/BTCPayServer/Controllers/InvoiceController.UI.cs @@ -42,6 +42,51 @@ namespace BTCPayServer.Controllers { public partial class InvoiceController { + + [HttpGet] + [Route("invoices/{invoiceId}/deliveries/{deliveryId}/request")] + [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)] + public async Task WebhookDelivery(string invoiceId, string deliveryId) + { + var invoice = (await _InvoiceRepository.GetInvoices(new InvoiceQuery() + { + InvoiceId = new[] { invoiceId }, + UserId = GetUserId() + })).FirstOrDefault(); + if (invoice is null) + return NotFound(); + var delivery = await _InvoiceRepository.GetWebhookDelivery(invoiceId, deliveryId); + if (delivery is null) + return NotFound(); + return this.File(delivery.GetBlob().Request, "application/json"); + } + [HttpPost] + [Route("invoices/{invoiceId}/deliveries/{deliveryId}/redeliver")] + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] + public async Task RedeliverWebhook(string storeId, string invoiceId, string deliveryId) + { + var invoice = (await _InvoiceRepository.GetInvoices(new InvoiceQuery() + { + InvoiceId = new[] { invoiceId }, + StoreId = new[] { storeId }, + UserId = GetUserId() + })).FirstOrDefault(); + if (invoice is null) + return NotFound(); + var delivery = await _InvoiceRepository.GetWebhookDelivery(invoiceId, deliveryId); + if (delivery is null) + return NotFound(); + var newDeliveryId = await WebhookNotificationManager.Redeliver(deliveryId); + if (newDeliveryId is null) + return NotFound(); + TempData[WellKnownTempData.SuccessMessage] = "Successfully planned a redelivery"; + return RedirectToAction(nameof(Invoice), + new + { + invoiceId + }); + } + [HttpGet] [Route("invoices/{invoiceId}")] [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)] @@ -61,6 +106,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, @@ -83,6 +129,9 @@ namespace BTCPayServer.Controllers PosData = PosDataParser.ParsePosData(invoice.Metadata.PosData), Archived = invoice.Archived, CanRefund = CanRefund(invoice.GetInvoiceState()), + Deliveries = (await _InvoiceRepository.GetWebhookDeliveries(invoiceId)) + .Select(c => new Models.StoreViewModels.DeliveryViewModel(c)) + .ToList() }; model.Addresses = invoice.HistoricalAddresses.Select(h => new InvoiceDetailsModel.AddressModel diff --git a/BTCPayServer/Controllers/InvoiceController.cs b/BTCPayServer/Controllers/InvoiceController.cs index bce95b717..f14a4d9f4 100644 --- a/BTCPayServer/Controllers/InvoiceController.cs +++ b/BTCPayServer/Controllers/InvoiceController.cs @@ -45,6 +45,9 @@ namespace BTCPayServer.Controllers private readonly ApplicationDbContextFactory _dbContextFactory; private readonly PullPaymentHostedService _paymentHostedService; readonly IServiceProvider _ServiceProvider; + + public WebhookNotificationManager WebhookNotificationManager { get; } + public InvoiceController( IServiceProvider serviceProvider, InvoiceRepository invoiceRepository, @@ -57,7 +60,8 @@ namespace BTCPayServer.Controllers BTCPayNetworkProvider networkProvider, PaymentMethodHandlerDictionary paymentMethodHandlerDictionary, ApplicationDbContextFactory dbContextFactory, - PullPaymentHostedService paymentHostedService) + PullPaymentHostedService paymentHostedService, + WebhookNotificationManager webhookNotificationManager) { _ServiceProvider = serviceProvider; _CurrencyNameTable = currencyNameTable ?? throw new ArgumentNullException(nameof(currencyNameTable)); @@ -70,6 +74,7 @@ namespace BTCPayServer.Controllers _paymentMethodHandlerDictionary = paymentMethodHandlerDictionary; _dbContextFactory = dbContextFactory; _paymentHostedService = paymentHostedService; + WebhookNotificationManager = webhookNotificationManager; _CSP = csp; } diff --git a/BTCPayServer/Controllers/ManageController.APIKeys.cs b/BTCPayServer/Controllers/ManageController.APIKeys.cs index 0ced5c0ff..adafd5c29 100644 --- a/BTCPayServer/Controllers/ManageController.APIKeys.cs +++ b/BTCPayServer/Controllers/ManageController.APIKeys.cs @@ -467,6 +467,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")}, diff --git a/BTCPayServer/Controllers/StoresController.Integrations.cs b/BTCPayServer/Controllers/StoresController.Integrations.cs index 6230d549b..503a7f43b 100644 --- a/BTCPayServer/Controllers/StoresController.Integrations.cs +++ b/BTCPayServer/Controllers/StoresController.Integrations.cs @@ -1,12 +1,16 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Globalization; using System.IO; using System.Linq; using System.Net.Http; +using System.Text; using System.Threading.Tasks; +using BTCPayServer.Client; using BTCPayServer.Client.Models; using BTCPayServer.Data; +using BTCPayServer.Models; using BTCPayServer.Models.StoreViewModels; using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Shopify; @@ -16,6 +20,10 @@ using BTCPayServer.Services.Stores; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NBitcoin; +using NBitcoin.DataEncoders; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; using NicolasDorier.RateLimits; @@ -171,6 +179,127 @@ namespace BTCPayServer.Controllers return View("Integrations", vm); } + [HttpGet] + [Route("{storeId}/webhooks")] + public async Task Webhooks() + { + var webhooks = await this._Repo.GetWebhooks(CurrentStore.Id); + return View(nameof(Webhooks), new WebhooksViewModel() + { + Webhooks = webhooks.Select(w => new WebhooksViewModel.WebhookViewModel() + { + Id = w.Id, + Url = w.GetBlob().Url + }).ToArray() + }); + } + [HttpGet] + [Route("{storeId}/webhooks/new")] + public IActionResult NewWebhook() + { + return View(nameof(ModifyWebhook), new EditWebhookViewModel() + { + Active = true, + Everything = true, + IsNew = true, + Secret = Encoders.Base58.EncodeData(RandomUtils.GetBytes(20)) + }); + } + + [HttpGet] + [Route("{storeId}/webhooks/{webhookId}/remove")] + public async Task DeleteWebhook(string webhookId) + { + var webhook = await _Repo.GetWebhook(CurrentStore.Id, webhookId); + if (webhook is null) + return NotFound(); + return View("Confirm", new ConfirmModel() + { + Title = $"Delete a webhook", + Description = "This webhook will be removed from this store, do you wish to continue?", + Action = "Delete" + }); + } + + [HttpPost] + [Route("{storeId}/webhooks/{webhookId}/remove")] + public async Task DeleteWebhookPost(string webhookId) + { + var webhook = await _Repo.GetWebhook(CurrentStore.Id, webhookId); + if (webhook is null) + return NotFound(); + await _Repo.DeleteWebhook(CurrentStore.Id, webhookId); + TempData[WellKnownTempData.SuccessMessage] = "Webhook successfully deleted"; + return RedirectToAction(nameof(Webhooks), new { storeId = CurrentStore.Id }); + } + + [HttpPost] + [Route("{storeId}/webhooks/new")] + public async Task NewWebhook(string storeId, EditWebhookViewModel viewModel) + { + if (!ModelState.IsValid) + return View(viewModel); + + var webhookId = await _Repo.CreateWebhook(CurrentStore.Id, viewModel.CreateBlob()); + TempData[WellKnownTempData.SuccessMessage] = "The webhook has been created"; + return RedirectToAction(nameof(Webhooks), new { storeId }); + } + [HttpGet] + [Route("{storeId}/webhooks/{webhookId}")] + public async Task ModifyWebhook(string webhookId) + { + var webhook = await _Repo.GetWebhook(CurrentStore.Id, webhookId); + if (webhook is null) + return NotFound(); + var blob = webhook.GetBlob(); + var deliveries = await _Repo.GetWebhookDeliveries(CurrentStore.Id, webhookId, 20); + return View(nameof(ModifyWebhook), new EditWebhookViewModel(blob) + { + Deliveries = deliveries + .Select(s => new DeliveryViewModel(s)).ToList() + }); + } + [HttpPost] + [Route("{storeId}/webhooks/{webhookId}")] + public async Task ModifyWebhook(string webhookId, EditWebhookViewModel viewModel) + { + var webhook = await _Repo.GetWebhook(CurrentStore.Id, webhookId); + if (webhook is null) + return NotFound(); + + await _Repo.UpdateWebhook(CurrentStore.Id, webhookId, viewModel.CreateBlob()); + TempData[WellKnownTempData.SuccessMessage] = "The webhook has been updated"; + return RedirectToAction(nameof(Webhooks), new { storeId = CurrentStore.Id }); + } + + [HttpPost] + [Route("{storeId}/webhooks/{webhookId}/deliveries/{deliveryId}/redeliver")] + public async Task RedeliverWebhook(string webhookId, string deliveryId) + { + var delivery = await _Repo.GetWebhookDelivery(CurrentStore.Id, webhookId, deliveryId); + if (delivery is null) + return NotFound(); + var newDeliveryId = await WebhookNotificationManager.Redeliver(deliveryId); + if (newDeliveryId is null) + return NotFound(); + TempData[WellKnownTempData.SuccessMessage] = "Successfully planned a redelivery"; + return RedirectToAction(nameof(ModifyWebhook), + new + { + storeId = CurrentStore.Id, + webhookId + }); + } + [HttpGet] + [Route("{storeId}/webhooks/{webhookId}/deliveries/{deliveryId}/request")] + public async Task WebhookDelivery(string webhookId, string deliveryId) + { + var delivery = await _Repo.GetWebhookDelivery(CurrentStore.Id, webhookId, deliveryId); + if (delivery is null) + return NotFound(); + return this.File(delivery.GetBlob().Request, "application/json"); + } + [HttpPost] [Route("{storeId}/integrations/shopify")] public async Task Integrations([FromServices] IHttpClientFactory clientFactory, diff --git a/BTCPayServer/Controllers/StoresController.cs b/BTCPayServer/Controllers/StoresController.cs index 8e005f822..886233820 100644 --- a/BTCPayServer/Controllers/StoresController.cs +++ b/BTCPayServer/Controllers/StoresController.cs @@ -66,7 +66,8 @@ namespace BTCPayServer.Controllers EventAggregator eventAggregator, CssThemeManager cssThemeManager, AppService appService, - IWebHostEnvironment webHostEnvironment) + IWebHostEnvironment webHostEnvironment, + WebhookNotificationManager webhookNotificationManager) { _RateFactory = rateFactory; _Repo = repo; @@ -81,6 +82,7 @@ namespace BTCPayServer.Controllers _CssThemeManager = cssThemeManager; _appService = appService; _webHostEnvironment = webHostEnvironment; + WebhookNotificationManager = webhookNotificationManager; _EventAggregator = eventAggregator; _NetworkProvider = networkProvider; _ExplorerProvider = explorerProvider; @@ -794,6 +796,7 @@ namespace BTCPayServer.Controllers } public string GeneratedPairingCode { get; set; } + public WebhookNotificationManager WebhookNotificationManager { get; } [HttpGet] [Route("{storeId}/Tokens/Create")] diff --git a/BTCPayServer/Data/PaymentDataExtensions.cs b/BTCPayServer/Data/PaymentDataExtensions.cs index 45c267b75..199ca9db7 100644 --- a/BTCPayServer/Data/PaymentDataExtensions.cs +++ b/BTCPayServer/Data/PaymentDataExtensions.cs @@ -1,3 +1,4 @@ +using System.Runtime.InteropServices; using BTCPayServer.Services.Invoices; using Newtonsoft.Json.Linq; diff --git a/BTCPayServer/Data/WebhookDataExtensions.cs b/BTCPayServer/Data/WebhookDataExtensions.cs new file mode 100644 index 000000000..53de1037a --- /dev/null +++ b/BTCPayServer/Data/WebhookDataExtensions.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using BTCPayServer.Client.Models; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using SshNet.Security.Cryptography; + +namespace BTCPayServer.Data +{ + public class AuthorizedWebhookEvents + { + public bool Everything { get; set; } + + [JsonProperty(ItemConverterType = typeof(Newtonsoft.Json.Converters.StringEnumConverter))] + public WebhookEventType[] SpecificEvents { get; set; } = Array.Empty(); + public bool Match(WebhookEventType evt) + { + return Everything || SpecificEvents.Contains(evt); + } + } + + + public class WebhookDeliveryBlob + { + [JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))] + public WebhookDeliveryStatus Status { get; set; } + public int? HttpCode { get; set; } + public string ErrorMessage { get; set; } + public byte[] Request { get; set; } + public T ReadRequestAs() + { + return JsonConvert.DeserializeObject(UTF8Encoding.UTF8.GetString(Request), HostedServices.WebhookNotificationManager.DefaultSerializerSettings); + } + } + public class WebhookBlob + { + public string Url { get; set; } + public bool Active { get; set; } = true; + public string Secret { get; set; } + public bool AutomaticRedelivery { get; set; } + public AuthorizedWebhookEvents AuthorizedEvents { get; set; } + } + public static class WebhookDataExtensions + { + public static WebhookBlob GetBlob(this WebhookData webhook) + { + return JsonConvert.DeserializeObject(Encoding.UTF8.GetString(webhook.Blob)); + } + public static void SetBlob(this WebhookData webhook, WebhookBlob blob) + { + webhook.Blob = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(blob)); + } + public static WebhookDeliveryBlob GetBlob(this WebhookDeliveryData webhook) + { + return JsonConvert.DeserializeObject(ZipUtils.Unzip(webhook.Blob), HostedServices.WebhookNotificationManager.DefaultSerializerSettings); + } + public static void SetBlob(this WebhookDeliveryData webhook, WebhookDeliveryBlob blob) + { + webhook.Blob = ZipUtils.Zip(JsonConvert.SerializeObject(blob, Formatting.None, HostedServices.WebhookNotificationManager.DefaultSerializerSettings)); + } + } +} diff --git a/BTCPayServer/Events/InvoiceEvent.cs b/BTCPayServer/Events/InvoiceEvent.cs index f6839b9f8..783f2e0be 100644 --- a/BTCPayServer/Events/InvoiceEvent.cs +++ b/BTCPayServer/Events/InvoiceEvent.cs @@ -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}, diff --git a/BTCPayServer/HostedServices/EventHostedServiceBase.cs b/BTCPayServer/HostedServices/EventHostedServiceBase.cs index e22bc01b9..b1de13f80 100644 --- a/BTCPayServer/HostedServices/EventHostedServiceBase.cs +++ b/BTCPayServer/HostedServices/EventHostedServiceBase.cs @@ -16,7 +16,7 @@ namespace BTCPayServer.HostedServices private List _Subscriptions; private CancellationTokenSource _Cts; - + public CancellationToken CancellationToken => _Cts.Token; public EventHostedServiceBase(EventAggregator eventAggregator) { _EventAggregator = eventAggregator; @@ -61,6 +61,11 @@ namespace BTCPayServer.HostedServices _Subscriptions.Add(_EventAggregator.Subscribe(e => _Events.Writer.TryWrite(e))); } + protected void PushEvent(object obj) + { + _Events.Writer.TryWrite(obj); + } + public virtual Task StartAsync(CancellationToken cancellationToken) { _Subscriptions = new List(); diff --git a/BTCPayServer/HostedServices/WebhookNotificationManager.cs b/BTCPayServer/HostedServices/WebhookNotificationManager.cs new file mode 100644 index 000000000..319936f08 --- /dev/null +++ b/BTCPayServer/HostedServices/WebhookNotificationManager.cs @@ -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 +{ + /// + /// This class send webhook notifications + /// It also make sure the events sent to a webhook are sent in order to the webhook + /// + public class WebhookNotificationManager : EventHostedServiceBase + { + readonly Encoding UTF8 = new UTF8Encoding(false); + public readonly static JsonSerializerSettings DefaultSerializerSettings; + static WebhookNotificationManager() + { + DefaultSerializerSettings = 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> _InvoiceEventsByWebhookId = new Dictionary>(); + public StoreRepository StoreRepository { get; } + public IHttpClientFactory HttpClientFactory { get; } + + public WebhookNotificationManager(EventAggregator eventAggregator, + StoreRepository storeRepository, + IHttpClientFactory httpClientFactory) : base(eventAggregator) + { + StoreRepository = storeRepository; + HttpClientFactory = httpClientFactory; + } + + protected override void SubscribeToEvents() + { + Subscribe(); + } + + public async Task Redeliver(string deliveryId) + { + var deliveryRequest = await CreateRedeliveryRequest(deliveryId); + EnqueueDelivery(deliveryRequest); + return deliveryRequest.Delivery.Id; + } + + private async Task CreateRedeliveryRequest(string deliveryId) + { + using var ctx = StoreRepository.CreateDbContext(); + var webhookDelivery = await ctx.WebhookDeliveries.AsNoTracking() + .Where(o => o.Id == deliveryId) + .Select(o => new + { + Webhook = o.Webhook, + Delivery = o + }) + .FirstOrDefaultAsync(); + if (webhookDelivery is null) + return null; + var oldDeliveryBlob = webhookDelivery.Delivery.GetBlob(); + var newDelivery = NewDelivery(); + newDelivery.WebhookId = webhookDelivery.Webhook.Id; + var newDeliveryBlob = new WebhookDeliveryBlob(); + newDeliveryBlob.Request = oldDeliveryBlob.Request; + var webhookEvent = newDeliveryBlob.ReadRequestAs(); + webhookEvent.DeliveryId = newDelivery.Id; + webhookEvent.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(); + _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 channel) + { + await foreach (var originalCtx in channel.Reader.ReadAllAsync()) + { + try + { + var ctx = originalCtx; + var wh = (await StoreRepository.GetWebhook(ctx.WebhookId)).GetBlob(); + if (!ShouldDeliver(ctx.WebhookEvent.Type, wh)) + continue; + var result = await SendDelivery(ctx); + if (ctx.WebhookBlob.AutomaticRedelivery && + !result.Success && + result.DeliveryId is string) + { + var originalDeliveryId = result.DeliveryId; + foreach (var wait in new[] + { + TimeSpan.FromSeconds(10), + TimeSpan.FromMinutes(1), + TimeSpan.FromMinutes(10), + TimeSpan.FromMinutes(10), + TimeSpan.FromMinutes(10), + TimeSpan.FromMinutes(10), + TimeSpan.FromMinutes(10), + TimeSpan.FromMinutes(10), + }) + { + await Task.Delay(wait, CancellationToken); + ctx = await CreateRedeliveryRequest(originalDeliveryId); + // This may have changed + if (!ctx.WebhookBlob.AutomaticRedelivery || + !ShouldDeliver(ctx.WebhookEvent.Type, ctx.WebhookBlob)) + break; + result = await SendDelivery(ctx); + if (result.Success) + break; + } + } + } + catch when (CancellationToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + Logs.PayServer.LogError(ex, "Unexpected error when processing a webhook"); + } + } + } + + private static bool ShouldDeliver(WebhookEventType type, WebhookBlob wh) + { + return wh.Active && wh.AuthorizedEvents.Match(type); + } + + class DeliveryResult + { + public string DeliveryId { get; set; } + public bool Success { get; set; } + } + private async Task SendDelivery(WebhookDeliveryRequest ctx) + { + var uri = new Uri(ctx.WebhookBlob.Url, UriKind.Absolute); + var httpClient = GetClient(uri); + using var request = new HttpRequestMessage(); + request.RequestUri = uri; + request.Method = HttpMethod.Post; + byte[] bytes = ToBytes(ctx.WebhookEvent); + var content = new ByteArrayContent(bytes); + content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + using var hmac = new System.Security.Cryptography.HMACSHA256(UTF8.GetBytes(ctx.WebhookBlob.Secret ?? string.Empty)); + var sig = Encoders.Hex.EncodeData(hmac.ComputeHash(bytes)); + content.Headers.Add("BTCPay-Sig", $"sha256={sig}"); + request.Content = content; + var deliveryBlob = ctx.Delivery.Blob is null ? new WebhookDeliveryBlob() : ctx.Delivery.GetBlob(); + deliveryBlob.Request = bytes; + try + { + using var response = await httpClient.SendAsync(request, CancellationToken); + if (!response.IsSuccessStatusCode) + { + deliveryBlob.Status = WebhookDeliveryStatus.HttpError; + deliveryBlob.ErrorMessage = $"HTTP Error Code {(int)response.StatusCode}"; + } + else + { + deliveryBlob.Status = WebhookDeliveryStatus.HttpSuccess; + } + deliveryBlob.HttpCode = (int)response.StatusCode; + } + catch (Exception ex) when (!CancellationToken.IsCancellationRequested) + { + deliveryBlob.Status = WebhookDeliveryStatus.Failed; + deliveryBlob.ErrorMessage = ex.Message; + } + ctx.Delivery.SetBlob(deliveryBlob); + await StoreRepository.AddWebhookDelivery(ctx.Delivery); + return new DeliveryResult() { Success = deliveryBlob.ErrorMessage is null, DeliveryId = ctx.Delivery.Id }; + } + + private byte[] ToBytes(WebhookEvent webhookEvent) + { + var str = JsonConvert.SerializeObject(webhookEvent, Formatting.Indented, DefaultSerializerSettings); + var bytes = UTF8.GetBytes(str); + return bytes; + } + + private static Data.WebhookDeliveryData NewDelivery() + { + var delivery = new Data.WebhookDeliveryData(); + delivery.Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(16)); + delivery.Timestamp = DateTimeOffset.UtcNow; + return delivery; + } + } +} diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index 27da5727e..5100daa72 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -217,7 +217,8 @@ namespace BTCPayServer.Hosting services.AddSingleton(); services.AddSingleton(o => o.GetRequiredService()); - + services.AddSingleton(); + services.AddSingleton(o => o.GetRequiredService()); services.AddSingleton(); services.AddSingleton(o => o.GetRequiredService()); diff --git a/BTCPayServer/Models/InvoicingModels/InvoiceDetailsModel.cs b/BTCPayServer/Models/InvoicingModels/InvoiceDetailsModel.cs index 2052f3365..c27d1dd2c 100644 --- a/BTCPayServer/Models/InvoicingModels/InvoiceDetailsModel.cs +++ b/BTCPayServer/Models/InvoicingModels/InvoiceDetailsModel.cs @@ -79,9 +79,12 @@ namespace BTCPayServer.Models.InvoicingModels get; set; } + + public List Deliveries { get; set; } = new List(); public string TaxIncluded { get; set; } public string TransactionSpeed { get; set; } + public string StoreId { get; set; } public object StoreName { get; diff --git a/BTCPayServer/Models/StoreViewModels/EditWebhookViewModel.cs b/BTCPayServer/Models/StoreViewModels/EditWebhookViewModel.cs new file mode 100644 index 000000000..3b31b261c --- /dev/null +++ b/BTCPayServer/Models/StoreViewModels/EditWebhookViewModel.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Threading.Tasks; +using BTCPayServer.Client.Models; +using BTCPayServer.Data; +using BTCPayServer.Validation; + +namespace BTCPayServer.Models.StoreViewModels +{ + public class DeliveryViewModel + { + public DeliveryViewModel() + { + + } + public DeliveryViewModel(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().Type; + WebhookId = s.Id; + } + public string Id { get; set; } + public DateTimeOffset Time { get; set; } + public WebhookEventType Type { get; private set; } + public string WebhookId { get; set; } + public bool Success { get; set; } + public string ErrorMessage { get; set; } + } + public class EditWebhookViewModel + { + public EditWebhookViewModel() + { + + } + public EditWebhookViewModel(WebhookBlob blob) + { + Active = blob.Active; + AutomaticRedelivery = blob.AutomaticRedelivery; + Everything = blob.AuthorizedEvents.Everything; + Events = blob.AuthorizedEvents.SpecificEvents; + PayloadUrl = blob.Url; + Secret = blob.Secret; + IsNew = false; + } + public bool IsNew { get; set; } + public bool Active { get; set; } + public bool AutomaticRedelivery { get; set; } + public bool Everything { get; set; } + public WebhookEventType[] Events { get; set; } = Array.Empty(); + [Uri] + [Required] + public string PayloadUrl { get; set; } + [MaxLength(64)] + public string Secret { get; set; } + + public List Deliveries { get; set; } = new List(); + + public WebhookBlob CreateBlob() + { + return new WebhookBlob() + { + Active = Active, + Secret = Secret, + AutomaticRedelivery = AutomaticRedelivery, + Url = new Uri(PayloadUrl, UriKind.Absolute).AbsoluteUri, + AuthorizedEvents = new AuthorizedWebhookEvents() + { + Everything = Everything, + SpecificEvents = Events + } + }; + } + } +} diff --git a/BTCPayServer/Models/StoreViewModels/WebhooksViewModel.cs b/BTCPayServer/Models/StoreViewModels/WebhooksViewModel.cs new file mode 100644 index 000000000..b46bec728 --- /dev/null +++ b/BTCPayServer/Models/StoreViewModels/WebhooksViewModel.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace BTCPayServer.Models.StoreViewModels +{ + public class WebhooksViewModel + { + public class WebhookViewModel + { + public string Id { get; set; } + public string Url { get; set; } + } + public WebhookViewModel[] Webhooks { get; set; } + } +} diff --git a/BTCPayServer/Payments/PayJoin/PayJoinExtensions.cs b/BTCPayServer/Payments/PayJoin/PayJoinExtensions.cs index 0e5f234ad..d3c1d85b4 100644 --- a/BTCPayServer/Payments/PayJoin/PayJoinExtensions.cs +++ b/BTCPayServer/Payments/PayJoin/PayJoinExtensions.cs @@ -19,6 +19,9 @@ namespace BTCPayServer.Payments.PayJoin services.AddHttpClient(PayjoinClient.PayjoinOnionNamedClient) .ConfigureHttpClient(h => h.DefaultRequestHeaders.ConnectionClose = true) .ConfigurePrimaryHttpMessageHandler(); + services.AddHttpClient(WebhookNotificationManager.OnionNamedClient) + .ConfigureHttpClient(h => h.DefaultRequestHeaders.ConnectionClose = true) + .ConfigurePrimaryHttpMessageHandler(); } } } diff --git a/BTCPayServer/Services/Invoices/InvoiceRepository.cs b/BTCPayServer/Services/Invoices/InvoiceRepository.cs index d9e704d79..35a3db316 100644 --- a/BTCPayServer/Services/Invoices/InvoiceRepository.cs +++ b/BTCPayServer/Services/Invoices/InvoiceRepository.cs @@ -2,12 +2,14 @@ using System; using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Runtime.CompilerServices; using System.Threading.Tasks; using BTCPayServer.Client.Models; using BTCPayServer.Data; using BTCPayServer.Events; using BTCPayServer.Logging; using BTCPayServer.Models.InvoicingModels; +using BTCPayServer.Models.StoreViewModels; using BTCPayServer.Payments; using DBriize; using Microsoft.EntityFrameworkCore; @@ -59,6 +61,15 @@ retry: _eventAggregator = eventAggregator; } + public async Task GetWebhookDelivery(string invoiceId, string deliveryId) + { + using var ctx = _ContextFactory.CreateContext(); + return await ctx.InvoiceWebhookDeliveries + .Where(d => d.InvoiceId == invoiceId && d.DeliveryId == deliveryId) + .Select(d => d.Delivery) + .FirstOrDefaultAsync(); + } + public InvoiceEntity CreateNewInvoice() { return new InvoiceEntity() @@ -107,6 +118,16 @@ retry: } } + public async Task> GetWebhookDeliveries(string invoiceId) + { + using var ctx = _ContextFactory.CreateContext(); + return await ctx.InvoiceWebhookDeliveries + .Where(s => s.InvoiceId == invoiceId) + .Select(s => s.Delivery) + .OrderByDescending(s => s.Timestamp) + .ToListAsync(); + } + public async Task GetAppsTaggingStore(string storeId) { if (storeId == null) diff --git a/BTCPayServer/Services/Stores/StoreRepository.cs b/BTCPayServer/Services/Stores/StoreRepository.cs index b41ff2f6f..b62d3cd27 100644 --- a/BTCPayServer/Services/Stores/StoreRepository.cs +++ b/BTCPayServer/Services/Stores/StoreRepository.cs @@ -13,7 +13,10 @@ namespace BTCPayServer.Services.Stores public class StoreRepository { private readonly ApplicationDbContextFactory _ContextFactory; - + public ApplicationDbContext CreateDbContext() + { + return _ContextFactory.CreateContext(); + } public StoreRepository(ApplicationDbContextFactory contextFactory) { _ContextFactory = contextFactory ?? throw new ArgumentNullException(nameof(contextFactory)); @@ -177,7 +180,7 @@ namespace BTCPayServer.Services.Stores ctx.Add(userStore); await ctx.SaveChangesAsync(); } - } + } public async Task CreateStore(string ownerId, string name) { @@ -193,6 +196,112 @@ namespace BTCPayServer.Services.Stores return store; } + public async Task GetWebhooks(string storeId) + { + using var ctx = _ContextFactory.CreateContext(); + return await ctx.StoreWebhooks + .Where(s => s.StoreId == storeId) + .Select(s => s.Webhook).ToArrayAsync(); + } + + public async Task GetWebhookDelivery(string storeId, string webhookId, string deliveryId) + { + using var ctx = _ContextFactory.CreateContext(); + return await ctx.StoreWebhooks + .Where(d => d.StoreId == storeId && d.WebhookId == webhookId) + .SelectMany(d => d.Webhook.Deliveries) + .Where(d => d.Id == deliveryId) + .FirstOrDefaultAsync(); + } + + public async Task AddWebhookDelivery(WebhookDeliveryData delivery) + { + using var ctx = _ContextFactory.CreateContext(); + ctx.WebhookDeliveries.Add(delivery); + var invoiceWebhookDelivery = delivery.GetBlob().ReadRequestAs(); + if (invoiceWebhookDelivery.InvoiceId != null) + { + ctx.InvoiceWebhookDeliveries.Add(new InvoiceWebhookDeliveryData() + { + InvoiceId = invoiceWebhookDelivery.InvoiceId, + DeliveryId = delivery.Id + }); + } + await ctx.SaveChangesAsync(); + } + + public async Task GetWebhookDeliveries(string storeId, string webhookId, int? count) + { + using var ctx = _ContextFactory.CreateContext(); + IQueryable 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 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 GetWebhook(string storeId, string webhookId) + { + var ctx = _ContextFactory.CreateContext(); + return await ctx.StoreWebhooks + .Where(s => s.StoreId == storeId && s.WebhookId == webhookId) + .Select(s => s.Webhook) + .FirstOrDefaultAsync(); + } + public async Task GetWebhook(string webhookId) + { + var ctx = _ContextFactory.CreateContext(); + return await ctx.StoreWebhooks + .Where(s => s.WebhookId == webhookId) + .Select(s => s.Webhook) + .FirstOrDefaultAsync(); + } + public async Task DeleteWebhook(string storeId, string webhookId) + { + var ctx = _ContextFactory.CreateContext(); + var hook = await ctx.StoreWebhooks + .Where(s => s.StoreId == storeId && s.WebhookId == webhookId) + .Select(s => s.Webhook) + .FirstOrDefaultAsync(); + if (hook is null) + return; + ctx.Webhooks.Remove(hook); + await ctx.SaveChangesAsync(); + } + + public async Task UpdateWebhook(string storeId, string webhookId, WebhookBlob webhookBlob) + { + var ctx = _ContextFactory.CreateContext(); + var hook = await ctx.StoreWebhooks + .Where(s => s.StoreId == storeId && s.WebhookId == webhookId) + .Select(s => s.Webhook) + .FirstOrDefaultAsync(); + if (hook is null) + return; + hook.SetBlob(webhookBlob); + await ctx.SaveChangesAsync(); + } + public async Task RemoveStore(string storeId, string userId) { using (var ctx = _ContextFactory.CreateContext()) @@ -225,6 +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; diff --git a/BTCPayServer/Views/Home/SwaggerDocs.cshtml b/BTCPayServer/Views/Home/SwaggerDocs.cshtml index dfd5742c5..3fe3e04ba 100644 --- a/BTCPayServer/Views/Home/SwaggerDocs.cshtml +++ b/BTCPayServer/Views/Home/SwaggerDocs.cshtml @@ -1,4 +1,4 @@ -@{ +@{ Layout = null; } @@ -23,6 +23,6 @@ - + diff --git a/BTCPayServer/Views/Invoice/Invoice.cshtml b/BTCPayServer/Views/Invoice/Invoice.cshtml index df19d0e79..1c7576254 100644 --- a/BTCPayServer/Views/Invoice/Invoice.cshtml +++ b/BTCPayServer/Views/Invoice/Invoice.cshtml @@ -57,7 +57,7 @@
-

Information

+

Information

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

Buyer information

+

Buyer information

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

Product information

+

Product information

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

Product information

+

Product information

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

Point of Sale Data

+

Point of Sale Data

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

Webhook deliveries

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

Events

+

Events

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

Webhooks settings

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

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

+
+
+
+ + +

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

+
+
+
+
+ + +
+
+

Events

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

Recent deliveries

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

Other actions

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

Webhooks

+
+
+

Webhooks allows BTCPayServer to send HTTP events related to your store

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