Prune webhook data from database

This commit is contained in:
nicolas.dorier
2023-05-28 23:44:10 +09:00
committed by Andrew Camilleri
parent 418b476725
commit 4e03c2523a
21 changed files with 515 additions and 161 deletions

View File

@@ -1,4 +1,5 @@
using System; using System;
using System.Data.Common;
using BTCPayServer.Abstractions.Models; using BTCPayServer.Abstractions.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Metadata;
@@ -21,7 +22,6 @@ namespace BTCPayServer.Abstractions.Contracts
} }
public abstract T CreateContext(); public abstract T CreateContext();
class CustomNpgsqlMigrationsSqlGenerator : NpgsqlMigrationsSqlGenerator class CustomNpgsqlMigrationsSqlGenerator : NpgsqlMigrationsSqlGenerator
{ {
#pragma warning disable EF1001 // Internal EF Core API usage. #pragma warning disable EF1001 // Internal EF Core API usage.

View File

@@ -51,7 +51,8 @@ namespace BTCPayServer.Client
{ {
if (message.StatusCode == System.Net.HttpStatusCode.UnprocessableEntity) if (message.StatusCode == System.Net.HttpStatusCode.UnprocessableEntity)
{ {
var err = JsonConvert.DeserializeObject<Models.GreenfieldValidationError[]>(await message.Content.ReadAsStringAsync()); var aa = await message.Content.ReadAsStringAsync();
var err = JsonConvert.DeserializeObject<Models.GreenfieldValidationError[]>(aa);
throw new GreenfieldValidationException(err); throw new GreenfieldValidationException(err);
} }
if (message.StatusCode == System.Net.HttpStatusCode.Forbidden) if (message.StatusCode == System.Net.HttpStatusCode.Forbidden)

View File

@@ -51,6 +51,10 @@ namespace BTCPayServer.Client.Models
public DateTimeOffset Timestamp { get; set; } public DateTimeOffset Timestamp { get; set; }
[JsonExtensionData] [JsonExtensionData]
public IDictionary<string, JToken> AdditionalData { get; set; } public IDictionary<string, JToken> AdditionalData { get; set; }
public bool IsPruned()
{
return DeliveryId is null;
}
public T ReadAs<T>() public T ReadAs<T>()
{ {
var str = JsonConvert.SerializeObject(this, DefaultSerializerSettings); var str = JsonConvert.SerializeObject(this, DefaultSerializerSettings);

View File

@@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
namespace BTCPayServer.Data namespace BTCPayServer.Data
{ {
public class WebhookDeliveryData : IHasBlobUntyped public class WebhookDeliveryData
{ {
[Key] [Key]
[MaxLength(25)] [MaxLength(25)]
@@ -17,10 +17,8 @@ namespace BTCPayServer.Data
[Required] [Required]
public DateTimeOffset Timestamp { get; set; } public DateTimeOffset Timestamp { get; set; }
[Obsolete("Use Blob2 instead")] public string Blob { get; set; }
public byte[] Blob { get; set; } public bool Pruned { get; set; }
public string Blob2 { get; set; }
internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade) internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
{ {
@@ -28,11 +26,11 @@ namespace BTCPayServer.Data
.HasOne(o => o.Webhook) .HasOne(o => o.Webhook)
.WithMany(a => a.Deliveries).OnDelete(DeleteBehavior.Cascade); .WithMany(a => a.Deliveries).OnDelete(DeleteBehavior.Cascade);
builder.Entity<WebhookDeliveryData>().HasIndex(o => o.WebhookId); builder.Entity<WebhookDeliveryData>().HasIndex(o => o.WebhookId);
builder.Entity<WebhookDeliveryData>().HasIndex(o => o.Timestamp);
if (databaseFacade.IsNpgsql()) if (databaseFacade.IsNpgsql())
{ {
builder.Entity<WebhookDeliveryData>() builder.Entity<WebhookDeliveryData>()
.Property(o => o.Blob2) .Property(o => o.Blob)
.HasColumnType("JSONB"); .HasColumnType("JSONB");
} }
} }

View File

@@ -0,0 +1,83 @@
using System;
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NBitcoin;
using Newtonsoft.Json;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20230529135505_WebhookDeliveriesCleanup")]
public partial class WebhookDeliveriesCleanup : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
if (migrationBuilder.IsNpgsql())
{
migrationBuilder.Sql("DROP TABLE IF EXISTS \"InvoiceWebhookDeliveries\", \"WebhookDeliveries\";");
migrationBuilder.CreateTable(
name: "WebhookDeliveries",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
WebhookId = table.Column<string>(type: "TEXT", nullable: false),
Timestamp = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
Pruned = table.Column<bool>(type: "BOOLEAN", nullable: false),
Blob = table.Column<string>(type: "JSONB", 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.CreateIndex(
name: "IX_WebhookDeliveries_WebhookId",
table: "WebhookDeliveries",
column: "WebhookId");
migrationBuilder.Sql("CREATE INDEX \"IX_WebhookDeliveries_Timestamp\" ON \"WebhookDeliveries\"(\"Timestamp\") WHERE \"Pruned\" IS FALSE");
migrationBuilder.CreateTable(
name: "InvoiceWebhookDeliveries",
columns: table => new
{
InvoiceId = table.Column<string>(type: "TEXT", nullable: false),
DeliveryId = table.Column<string>(type: "TEXT", 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);
});
}
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

View File

@@ -1019,12 +1019,12 @@ namespace BTCPayServer.Migrations
.HasMaxLength(25) .HasMaxLength(25)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<byte[]>("Blob") b.Property<string>("Blob")
.HasColumnType("BLOB");
b.Property<string>("Blob2")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<bool>("Pruned")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("Timestamp") b.Property<DateTimeOffset>("Timestamp")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -1035,6 +1035,8 @@ namespace BTCPayServer.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("Timestamp");
b.HasIndex("WebhookId"); b.HasIndex("WebhookId");
b.ToTable("WebhookDeliveries"); b.ToTable("WebhookDeliveries");

View File

@@ -1432,7 +1432,7 @@ namespace BTCPayServer.Tests
Assert.False(hook.AutomaticRedelivery); Assert.False(hook.AutomaticRedelivery);
Assert.Equal(fakeServer.ServerUri.AbsoluteUri, hook.Url); Assert.Equal(fakeServer.ServerUri.AbsoluteUri, hook.Url);
} }
using var tester = CreateServerTester(); using var tester = CreateServerTester(newDb: true);
using var fakeServer = new FakeServer(); using var fakeServer = new FakeServer();
await fakeServer.Start(); await fakeServer.Start();
await tester.StartAsync(); await tester.StartAsync();
@@ -1509,6 +1509,14 @@ namespace BTCPayServer.Tests
clientProfile = await user.CreateClient(Policies.CanModifyStoreSettings, Policies.CanCreateInvoice); clientProfile = await user.CreateClient(Policies.CanModifyStoreSettings, Policies.CanCreateInvoice);
await clientProfile.GetWebhookDeliveryRequest(user.StoreId, hook.Id, newDeliveryId); await clientProfile.GetWebhookDeliveryRequest(user.StoreId, hook.Id, newDeliveryId);
TestLogs.LogInformation("Can prune deliveries");
var cleanup = tester.PayTester.GetService<HostedServices.CleanupWebhookDeliveriesTask>();
cleanup.BatchSize = 1;
cleanup.PruneAfter = TimeSpan.Zero;
await cleanup.Do(default);
await AssertHttpError(409, () => clientProfile.RedeliverWebhook(user.StoreId, hook.Id, delivery.Id));
TestLogs.LogInformation("Testing corner cases"); TestLogs.LogInformation("Testing corner cases");
Assert.Null(await clientProfile.GetWebhookDeliveryRequest(user.StoreId, "lol", newDeliveryId)); 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, hook.Id, "lol"));

View File

@@ -153,15 +153,24 @@ namespace BTCPayServer.Controllers.Greenfield
var delivery = await StoreRepository.GetWebhookDelivery(CurrentStoreId, webhookId, deliveryId); var delivery = await StoreRepository.GetWebhookDelivery(CurrentStoreId, webhookId, deliveryId);
if (delivery is null) if (delivery is null)
return WebhookDeliveryNotFound(); return WebhookDeliveryNotFound();
if (delivery.GetBlob().IsPruned())
return WebhookDeliveryPruned();
return this.Ok(new JValue(await WebhookSender.Redeliver(deliveryId))); return this.Ok(new JValue(await WebhookSender.Redeliver(deliveryId)));
} }
private IActionResult WebhookDeliveryPruned()
{
return this.CreateAPIError(409, "webhookdelivery-pruned", "This webhook delivery has been pruned, so it can't be redelivered");
}
[HttpGet("~/api/v1/stores/{storeId}/webhooks/{webhookId}/deliveries/{deliveryId}/request")] [HttpGet("~/api/v1/stores/{storeId}/webhooks/{webhookId}/deliveries/{deliveryId}/request")]
public async Task<IActionResult> GetDeliveryRequest(string storeId, string webhookId, string deliveryId) public async Task<IActionResult> GetDeliveryRequest(string storeId, string webhookId, string deliveryId)
{ {
var delivery = await StoreRepository.GetWebhookDelivery(CurrentStoreId, webhookId, deliveryId); var delivery = await StoreRepository.GetWebhookDelivery(CurrentStoreId, webhookId, deliveryId);
if (delivery is null) if (delivery is null)
return WebhookDeliveryNotFound(); return WebhookDeliveryNotFound();
if (delivery.GetBlob().IsPruned())
return WebhookDeliveryPruned();
return File(delivery.GetBlob().Request, "application/json"); return File(delivery.GetBlob().Request, "application/json");
} }

View File

@@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel;
using System.Linq; using System.Linq;
using System.Text; using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -29,12 +30,30 @@ namespace BTCPayServer.Data
[JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))] [JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))]
public WebhookDeliveryStatus Status { get; set; } public WebhookDeliveryStatus Status { get; set; }
public int? HttpCode { get; set; } public int? HttpCode { get; set; }
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
public string ErrorMessage { get; set; } public string ErrorMessage { get; set; }
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
public byte[] Request { get; set; } public byte[] Request { get; set; }
public void Prune()
{
var request = JObject.Parse(UTF8Encoding.UTF8.GetString(Request));
foreach (var prop in request.Properties().ToList())
{
if (prop.Name == "type")
continue;
prop.Remove();
}
Request = UTF8Encoding.UTF8.GetBytes(request.ToString(Formatting.None));
}
public T ReadRequestAs<T>() public T ReadRequestAs<T>()
{ {
return JsonConvert.DeserializeObject<T>(UTF8Encoding.UTF8.GetString(Request), HostedServices.WebhookSender.DefaultSerializerSettings); return JsonConvert.DeserializeObject<T>(UTF8Encoding.UTF8.GetString(Request), HostedServices.WebhookSender.DefaultSerializerSettings);
} }
public bool IsPruned()
{
return ReadRequestAs<WebhookEvent>().IsPruned();
}
} }
public class WebhookBlob public class WebhookBlob
{ {
@@ -56,11 +75,17 @@ namespace BTCPayServer.Data
} }
public static WebhookDeliveryBlob GetBlob(this WebhookDeliveryData webhook) public static WebhookDeliveryBlob GetBlob(this WebhookDeliveryData webhook)
{ {
return webhook.HasTypedBlob<WebhookDeliveryBlob>().GetBlob(HostedServices.WebhookSender.DefaultSerializerSettings); if (webhook.Blob is null)
return null;
else
return JsonConvert.DeserializeObject<WebhookDeliveryBlob>(webhook.Blob, HostedServices.WebhookSender.DefaultSerializerSettings);
} }
public static void SetBlob(this WebhookDeliveryData webhook, WebhookDeliveryBlob blob) public static void SetBlob(this WebhookDeliveryData webhook, WebhookDeliveryBlob blob)
{ {
webhook.HasTypedBlob<WebhookDeliveryBlob>().SetBlob(blob, HostedServices.WebhookSender.DefaultSerializerSettings); if (blob is null)
webhook.Blob = null;
else
webhook.Blob = JsonConvert.SerializeObject(blob, HostedServices.WebhookSender.DefaultSerializerSettings);
} }
} }
} }

View File

@@ -13,6 +13,7 @@ using System.Threading.Tasks;
using BTCPayServer.BIP78.Sender; using BTCPayServer.BIP78.Sender;
using BTCPayServer.Configuration; using BTCPayServer.Configuration;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Lightning; using BTCPayServer.Lightning;
using BTCPayServer.Logging; using BTCPayServer.Logging;
using BTCPayServer.Models; using BTCPayServer.Models;
@@ -25,6 +26,7 @@ using BTCPayServer.Services.Wallets;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using NBitcoin; using NBitcoin;
using NBitcoin.Payment; using NBitcoin.Payment;
@@ -115,6 +117,14 @@ namespace BTCPayServer
return value; return value;
} }
public static IServiceCollection AddScheduledTask<T>(this IServiceCollection services, TimeSpan every)
where T : class, IPeriodicTask
{
services.AddSingleton<T>();
services.AddTransient<ScheduledTask>(o => new ScheduledTask(typeof(T), every));
return services;
}
public static PaymentMethodId GetpaymentMethodId(this InvoiceCryptoInfo info) public static PaymentMethodId GetpaymentMethodId(this InvoiceCryptoInfo info)
{ {
return new PaymentMethodId(info.CryptoCode, PaymentTypes.Parse(info.PaymentType)); return new PaymentMethodId(info.CryptoCode, PaymentTypes.Parse(info.PaymentType));

View File

@@ -0,0 +1,61 @@
using System;
using Dapper;
using System.Data.Common;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore;
using System.Collections.Generic;
namespace BTCPayServer.HostedServices
{
public class CleanupWebhookDeliveriesTask : IPeriodicTask
{
public CleanupWebhookDeliveriesTask(ApplicationDbContextFactory dbContextFactory)
{
DbContextFactory = dbContextFactory;
}
public ApplicationDbContextFactory DbContextFactory { get; }
public int BatchSize { get; set; } = 500;
public TimeSpan PruneAfter { get; set; } = TimeSpan.FromDays(60);
public async Task Do(CancellationToken cancellationToken)
{
await using var ctx = DbContextFactory.CreateContext();
if (!ctx.Database.IsNpgsql())
return;
var conn = ctx.Database.GetDbConnection();
bool pruned = false;
int offset = 0;
retry:
var rows = await conn.QueryAsync<WebhookDeliveryData>(@"
SELECT ""Id"", ""Blob""
FROM ""WebhookDeliveries""
WHERE ((now() - ""Timestamp"") > @PruneAfter) AND ""Pruned"" IS FALSE
ORDER BY ""Timestamp""
LIMIT @BatchSize OFFSET @offset
", new { PruneAfter, BatchSize, offset });
foreach (var d in rows)
{
var blob = d.GetBlob();
blob.Prune();
d.SetBlob(blob);
d.Pruned = true;
pruned = true;
}
if (pruned)
{
pruned = false;
await conn.ExecuteAsync("UPDATE \"WebhookDeliveries\" SET \"Blob\"=@Blob::JSONB, \"Pruned\"=@Pruned WHERE \"Id\"=@Id", rows);
if (rows.Count() == BatchSize)
{
offset += BatchSize;
goto retry;
}
}
}
}
}

View File

@@ -0,0 +1,10 @@
using System.Threading.Tasks;
using System.Threading;
namespace BTCPayServer.HostedServices
{
public interface IPeriodicTask
{
Task Do(CancellationToken cancellationToken);
}
}

View File

@@ -0,0 +1,92 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System;
using System.Linq;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
namespace BTCPayServer.HostedServices
{
public class PeriodicTaskLauncherHostedService : IHostedService
{
public PeriodicTaskLauncherHostedService(IServiceProvider serviceProvider, ILoggerFactory loggerFactory)
{
ServiceProvider = serviceProvider;
Logger = loggerFactory.CreateLogger("BTCPayServer.PeriodicTasks");
}
public IServiceProvider ServiceProvider { get; }
public ILogger Logger { get; }
Channel<ScheduledTask> jobs = Channel.CreateBounded<ScheduledTask>(100);
CancellationTokenSource cts;
public Task StartAsync(CancellationToken cancellationToken)
{
cts = new CancellationTokenSource();
foreach (var task in ServiceProvider.GetServices<ScheduledTask>())
jobs.Writer.TryWrite(task);
loop = Task.WhenAll(Enumerable.Range(0, 3).Select(_ => Loop(cts.Token)).ToArray());
return Task.CompletedTask;
}
Task loop;
private async Task Loop(CancellationToken token)
{
try
{
await foreach (var job in jobs.Reader.ReadAllAsync(token))
{
if (job.NextScheduled <= DateTimeOffset.UtcNow)
{
var t = (IPeriodicTask)ServiceProvider.GetService(job.PeriodicTaskType);
try
{
await t.Do(token);
}
catch when (token.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
Logger.LogError(ex, $"Unhandled error in periodic task {job.PeriodicTaskType.Name}");
}
finally
{
job.NextScheduled = DateTimeOffset.UtcNow + job.Every;
}
}
_ = Wait(job, token);
}
}
catch when (token.IsCancellationRequested)
{
}
}
private async Task Wait(ScheduledTask job, CancellationToken token)
{
var timeToWait = job.NextScheduled - DateTimeOffset.UtcNow;
try
{
await Task.Delay(timeToWait, token);
}
catch { }
while (await jobs.Writer.WaitToWriteAsync())
{
if (jobs.Writer.TryWrite(job))
break;
}
}
public async Task StopAsync(CancellationToken cancellationToken)
{
cts?.Cancel();
jobs.Writer.TryComplete();
if (loop is not null)
await loop;
}
}
}

View File

@@ -0,0 +1,16 @@
using System;
namespace BTCPayServer.HostedServices
{
public class ScheduledTask
{
public ScheduledTask(Type periodicTypeTask, TimeSpan every)
{
PeriodicTaskType = periodicTypeTask;
Every = every;
}
public Type PeriodicTaskType { get; set; }
public TimeSpan Every { get; set; } = TimeSpan.FromMinutes(5.0);
public DateTimeOffset NextScheduled { get; set; } = DateTimeOffset.UtcNow;
}
}

View File

@@ -105,6 +105,8 @@ namespace BTCPayServer.HostedServices
var newDeliveryBlob = new WebhookDeliveryBlob(); var newDeliveryBlob = new WebhookDeliveryBlob();
newDeliveryBlob.Request = oldDeliveryBlob.Request; newDeliveryBlob.Request = oldDeliveryBlob.Request;
var webhookEvent = newDeliveryBlob.ReadRequestAs<WebhookEvent>(); var webhookEvent = newDeliveryBlob.ReadRequestAs<WebhookEvent>();
if (webhookEvent.IsPruned())
return null;
webhookEvent.DeliveryId = newDelivery.Id; webhookEvent.DeliveryId = newDelivery.Id;
webhookEvent.WebhookId = webhookDelivery.Webhook.Id; webhookEvent.WebhookId = webhookDelivery.Webhook.Id;
// if we redelivered a redelivery, we still want the initial delivery here // if we redelivered a redelivery, we still want the initial delivery here

View File

@@ -350,6 +350,9 @@ namespace BTCPayServer.Hosting
services.AddSingleton<HostedServices.WebhookSender>(); services.AddSingleton<HostedServices.WebhookSender>();
services.AddSingleton<IHostedService, WebhookSender>(o => o.GetRequiredService<WebhookSender>()); services.AddSingleton<IHostedService, WebhookSender>(o => o.GetRequiredService<WebhookSender>());
services.AddSingleton<IHostedService, StoreEmailRuleProcessorSender>(); services.AddSingleton<IHostedService, StoreEmailRuleProcessorSender>();
services.AddSingleton<IHostedService, PeriodicTaskLauncherHostedService>();
services.AddScheduledTask<CleanupWebhookDeliveriesTask>(TimeSpan.FromHours(6.0));
services.AddHttpClient(WebhookSender.OnionNamedClient) services.AddHttpClient(WebhookSender.OnionNamedClient)
.ConfigurePrimaryHttpMessageHandler<Socks5HttpClientHandler>(); .ConfigurePrimaryHttpMessageHandler<Socks5HttpClientHandler>();
services.AddHttpClient(WebhookSender.LoopbackNamedClient) services.AddHttpClient(WebhookSender.LoopbackNamedClient)

View File

@@ -213,6 +213,9 @@ namespace BTCPayServer.Hosting
{ {
var typeMapping = t.EntityTypeMappings.Single(); var typeMapping = t.EntityTypeMappings.Single();
var query = (IQueryable<object>)otherContext.GetType().GetMethod("Set", new Type[0])!.MakeGenericMethod(typeMapping.EntityType.ClrType).Invoke(otherContext, null)!; var query = (IQueryable<object>)otherContext.GetType().GetMethod("Set", new Type[0])!.MakeGenericMethod(typeMapping.EntityType.ClrType).Invoke(otherContext, null)!;
if (t.Name == "WebhookDeliveries" ||
t.Name == "InvoiceWebhookDeliveries")
continue;
Logger.LogInformation($"Migrating table: " + t.Name); Logger.LogInformation($"Migrating table: " + t.Name);
List<PropertyInfo> datetimeProperties = new List<PropertyInfo>(); List<PropertyInfo> datetimeProperties = new List<PropertyInfo>();
foreach (var col in t.Columns) foreach (var col in t.Columns)

View File

@@ -22,13 +22,16 @@ namespace BTCPayServer.Models.StoreViewModels
Success = blob.Status == WebhookDeliveryStatus.HttpSuccess; Success = blob.Status == WebhookDeliveryStatus.HttpSuccess;
ErrorMessage = blob.ErrorMessage ?? "Success"; ErrorMessage = blob.ErrorMessage ?? "Success";
Time = s.Timestamp; Time = s.Timestamp;
Type = blob.ReadRequestAs<WebhookEvent>().Type; var evt = blob.ReadRequestAs<WebhookEvent>();
Type = evt.Type;
Pruned = evt.IsPruned();
WebhookId = s.Id; WebhookId = s.Id;
PayloadUrl = s.Webhook?.GetBlob().Url; PayloadUrl = s.Webhook?.GetBlob().Url;
} }
public string Id { get; set; } public string Id { get; set; }
public DateTimeOffset Time { get; set; } public DateTimeOffset Time { get; set; }
public WebhookEventType Type { get; private set; } public WebhookEventType Type { get; private set; }
public bool Pruned { get; set; }
public string WebhookId { get; set; } public string WebhookId { get; set; }
public bool Success { get; set; } public bool Success { get; set; }
public string ErrorMessage { get; set; } public string ErrorMessage { get; set; }

View File

@@ -9,9 +9,19 @@
@section PageHeadContent { @section PageHeadContent {
<meta name="robots" content="noindex,nofollow"> <meta name="robots" content="noindex,nofollow">
<style> <style>
#posData td > table:last-child { margin-bottom: 0 !important; } #posData td > table:last-child {
#posData table > tbody > tr:first-child > td > h4 { margin-top: 0 !important; } margin-bottom: 0 !important;
.invoice-information { display: flex; flex-wrap: wrap; gap: var(--btcpay-space-xl) var(--btcpay-space-xxl); } }
#posData table > tbody > tr:first-child > td > h4 {
margin-top: 0 !important;
}
.invoice-information {
display: flex;
flex-wrap: wrap;
gap: var(--btcpay-space-xl) var(--btcpay-space-xxl);
}
</style> </style>
} }
@@ -163,7 +173,7 @@
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title" id="RefundTitle">Issue Refund</h4> <h4 class="modal-title" id="RefundTitle">Issue Refund</h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
<vc:icon symbol="close"/> <vc:icon symbol="close" />
</button> </button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@@ -211,7 +221,7 @@
</div> </div>
</div> </div>
<partial name="_StatusMessage"/> <partial name="_StatusMessage" />
<div class="invoice-details"> <div class="invoice-details">
<div class="invoice-information mb-5"> <div class="invoice-information mb-5">
@@ -341,58 +351,59 @@
</table> </table>
</div> </div>
<div class="d-flex flex-column gap-5"> <div class="d-flex flex-column gap-5">
@if (!string.IsNullOrEmpty(Model.TypedMetadata.ItemCode) || @if (!string.IsNullOrEmpty(Model.TypedMetadata.ItemCode) ||
!string.IsNullOrEmpty(Model.TypedMetadata.ItemDesc) || !string.IsNullOrEmpty(Model.TypedMetadata.ItemDesc) ||
Model.TypedMetadata.TaxIncluded is not null) Model.TypedMetadata.TaxIncluded is not null)
{
<div>
<h3 class="mb-3">
<span>Product Information</span>
<a href="https://docs.btcpayserver.org/Development/InvoiceMetadata/" target="_blank" rel="noreferrer noopener">
<vc:icon symbol="info" />
</a>
</h3>
<table class="table mb-0">
@if (!string.IsNullOrEmpty(Model.TypedMetadata.ItemCode))
{
<tr>
<th class="fw-semibold">Item code</th>
<td>@Model.TypedMetadata.ItemCode</td>
</tr>
}
@if (!string.IsNullOrEmpty(Model.TypedMetadata.ItemDesc))
{
<tr>
<th class="fw-semibold">Item Description</th>
<td>@Model.TypedMetadata.ItemDesc</td>
</tr>
}
@if (Model.TaxIncluded is not null)
{
<tr>
<th class="fw-semibold">Tax Included</th>
<td>@Model.TaxIncluded</td>
</tr>
}
</table>
</div>
}
@if (Model.TypedMetadata.BuyerName is not null ||
Model.TypedMetadata.BuyerEmail is not null ||
Model.TypedMetadata.BuyerPhone is not null ||
Model.TypedMetadata.BuyerAddress1 is not null ||
Model.TypedMetadata.BuyerAddress2 is not null ||
Model.TypedMetadata.BuyerCity is not null ||
Model.TypedMetadata.BuyerState is not null ||
Model.TypedMetadata.BuyerCountry is not null ||
Model.TypedMetadata.BuyerZip is not null)
{ {
<div> <div>
<h3 class="mb-3"><span>Buyer Information</span> <h3 class="mb-3">
<a href="https://docs.btcpayserver.org/Development/InvoiceMetadata/" target="_blank" rel="noreferrer noopener"> <span>Product Information</span>
<vc:icon symbol="info" /> <a href="https://docs.btcpayserver.org/Development/InvoiceMetadata/" target="_blank" rel="noreferrer noopener">
</a> <vc:icon symbol="info" />
</h3> </a>
</h3>
<table class="table mb-0">
@if (!string.IsNullOrEmpty(Model.TypedMetadata.ItemCode))
{
<tr>
<th class="fw-semibold">Item code</th>
<td>@Model.TypedMetadata.ItemCode</td>
</tr>
}
@if (!string.IsNullOrEmpty(Model.TypedMetadata.ItemDesc))
{
<tr>
<th class="fw-semibold">Item Description</th>
<td>@Model.TypedMetadata.ItemDesc</td>
</tr>
}
@if (Model.TaxIncluded is not null)
{
<tr>
<th class="fw-semibold">Tax Included</th>
<td>@Model.TaxIncluded</td>
</tr>
}
</table>
</div>
}
@if (Model.TypedMetadata.BuyerName is not null ||
Model.TypedMetadata.BuyerEmail is not null ||
Model.TypedMetadata.BuyerPhone is not null ||
Model.TypedMetadata.BuyerAddress1 is not null ||
Model.TypedMetadata.BuyerAddress2 is not null ||
Model.TypedMetadata.BuyerCity is not null ||
Model.TypedMetadata.BuyerState is not null ||
Model.TypedMetadata.BuyerCountry is not null ||
Model.TypedMetadata.BuyerZip is not null)
{
<div>
<h3 class="mb-3">
<span>Buyer Information</span>
<a href="https://docs.btcpayserver.org/Development/InvoiceMetadata/" target="_blank" rel="noreferrer noopener">
<vc:icon symbol="info" />
</a>
</h3>
<table class="table mb-0"> <table class="table mb-0">
@if (Model.TypedMetadata.BuyerName is not null) @if (Model.TypedMetadata.BuyerName is not null)
{ {
@@ -465,12 +476,12 @@
@if (Model.AdditionalData.Any()) @if (Model.AdditionalData.Any())
{ {
<div> <div>
<h3 class="mb-3"> <h3 class="mb-3">
<span>Additional Information</span> <span>Additional Information</span>
<a href="https://docs.btcpayserver.org/Development/InvoiceMetadata/" target="_blank" rel="noreferrer noopener"> <a href="https://docs.btcpayserver.org/Development/InvoiceMetadata/" target="_blank" rel="noreferrer noopener">
<vc:icon symbol="info" /> <vc:icon symbol="info" />
</a> </a>
</h3> </h3>
<partial name="PosData" model="(Model.AdditionalData, 1)" /> <partial name="PosData" model="(Model.AdditionalData, 1)" />
</div> </div>
} }
@@ -478,7 +489,7 @@
</div> </div>
<h3 class="mb-3">Invoice Summary</h3> <h3 class="mb-3">Invoice Summary</h3>
<partial name="ListInvoicesPaymentsPartial" model="(Model, true)"/> <partial name="ListInvoicesPaymentsPartial" model="(Model, true)" />
@if (Model.Deliveries.Any()) @if (Model.Deliveries.Any())
{ {
@@ -487,46 +498,49 @@
<div class="table-responsive-xl"> <div class="table-responsive-xl">
<table class="table table-hover mb-5"> <table class="table table-hover mb-5">
<thead> <thead>
<tr> <tr>
<th>Status</th> <th>Status</th>
<th>ID</th> <th>ID</th>
<th>Type</th> <th>Type</th>
<th>Url</th> <th>Url</th>
<th>Date</th> <th>Date</th>
<th class="text-end">Action</th> <th class="text-end">Action</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@foreach (var delivery in Model.Deliveries) @foreach (var delivery in Model.Deliveries)
{ {
<tr> <tr>
<form asp-action="RedeliverWebhook" <form asp-action="RedeliverWebhook"
asp-route-storeId="@Model.StoreId" asp-route-storeId="@Model.StoreId"
asp-route-invoiceId="@Model.Id" asp-route-invoiceId="@Model.Id"
asp-route-deliveryId="@delivery.Id" asp-route-deliveryId="@delivery.Id"
method="post"> method="post">
<td> <td>
<span> <span>
@if (delivery.Success) @if (delivery.Success)
{ {
<span class="fa fa-check text-success" title="Success"></span> <span class="fa fa-check text-success" title="Success"></span>
} }
else else
{ {
<span class="fa fa-times text-danger" title="@delivery.ErrorMessage"></span> <span class="fa fa-times text-danger" title="@delivery.ErrorMessage"></span>
} }
</span> </span>
</td> </td>
<td> <td>
@if (!delivery.Pruned)
{
<span> <span>
<a asp-action="WebhookDelivery" <a asp-action="WebhookDelivery"
asp-route-invoiceId="@Model.Id" asp-route-invoiceId="@Model.Id"
asp-route-deliveryId="@delivery.Id" asp-route-deliveryId="@delivery.Id"
class="delivery-content" class="delivery-content"
target="_blank"> target="_blank">
@delivery.Id @delivery.Id
</a> </a>
</span> </span>
}
</td> </td>
<td> <td>
<span>@delivery.Type</span> <span>@delivery.Type</span>
@@ -538,19 +552,21 @@
</td> </td>
<td> <td>
<span> <span>
@delivery.Time.ToBrowserDate() @delivery.Time.ToBrowserDate()
</span> </span>
</td> </td>
<td class="text-end"> <td class="text-end">
@if (!delivery.Pruned) {
<button id="#redeliver-@delivery.Id" <button id="#redeliver-@delivery.Id"
type="submit" type="submit"
class="btn btn-link p-0 redeliver"> class="btn btn-link p-0 redeliver">
Redeliver Redeliver
</button> </button>
}
</td> </td>
</form> </form>
</tr> </tr>
} }
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -564,36 +580,36 @@
<div class="table-responsive-xl"> <div class="table-responsive-xl">
<table class="table table-hover mb-5"> <table class="table table-hover mb-5">
<thead> <thead>
<tr> <tr>
<th>Pull Payment</th> <th>Pull Payment</th>
<th>Amount</th> <th>Amount</th>
<th>Date</th> <th>Date</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@foreach (var refund in Model.Refunds) @foreach (var refund in Model.Refunds)
{ {
var blob = refund.PullPaymentData.GetBlob(); var blob = refund.PullPaymentData.GetBlob();
<tr> <tr>
<td> <td>
<span> <span>
<a asp-action="ViewPullPayment" asp-controller="UIPullPayment" <a asp-action="ViewPullPayment" asp-controller="UIPullPayment"
asp-route-pullPaymentId="@refund.PullPaymentDataId" asp-route-pullPaymentId="@refund.PullPaymentDataId"
class="delivery-content" class="delivery-content"
target="_blank"> target="_blank">
@refund.PullPaymentData.Id @refund.PullPaymentData.Id
</a> </a>
</span> </span>
</td> </td>
<td> <td>
<span>@blob.Limit @blob.Currency</span> <span>@blob.Limit @blob.Currency</span>
</td> </td>
<td> <td>
<span> @refund.PullPaymentData.StartDate.ToBrowserDate() </span> <span> @refund.PullPaymentData.StartDate.ToBrowserDate() </span>
</td> </td>
</tr> </tr>
} }
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -603,19 +619,19 @@
<h3 class="mb-0">Events</h3> <h3 class="mb-0">Events</h3>
<table class="table table-hover mt-3 mb-4"> <table class="table table-hover mt-3 mb-4">
<thead> <thead>
<tr> <tr>
<th>Date</th> <th>Date</th>
<th>Message</th> <th>Message</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@foreach (var evt in Model.Events) @foreach (var evt in Model.Events)
{ {
<tr class="text-@evt.GetCssClass()"> <tr class="text-@evt.GetCssClass()">
<td>@evt.Timestamp.ToBrowserDate()</td> <td>@evt.Timestamp.ToBrowserDate()</td>
<td>@evt.Message</td> <td>@evt.Message</td>
</tr> </tr>
} }
</tbody> </tbody>
</table> </table>
</section> </section>

View File

@@ -104,6 +104,7 @@
{ {
<span class="d-flex align-items-center fa fa-times text-danger" title="@delivery.ErrorMessage"></span> <span class="d-flex align-items-center fa fa-times text-danger" title="@delivery.ErrorMessage"></span>
} }
@if (!delivery.Pruned) {
<span class="ms-3"> <span class="ms-3">
<a asp-action="WebhookDelivery" <a asp-action="WebhookDelivery"
asp-route-storeId="@this.Context.GetRouteValue("storeId")" asp-route-storeId="@this.Context.GetRouteValue("storeId")"
@@ -113,6 +114,7 @@
@delivery.Id @delivery.Id
</a> </a>
</span> </span>
}
</span> </span>
<span class="d-flex align-items-center"> <span class="d-flex align-items-center">
<strong class="d-flex align-items-center text-muted small"> <strong class="d-flex align-items-center text-muted small">

View File

@@ -397,6 +397,9 @@
}, },
"404": { "404": {
"description": "The delivery does not exists." "description": "The delivery does not exists."
},
"409": {
"description": "`webhookdelivery-pruned`: This webhook delivery has been pruned, so it can't be redelivered."
} }
}, },
"security": [ "security": [
@@ -459,6 +462,9 @@
}, },
"404": { "404": {
"description": "The delivery does not exists." "description": "The delivery does not exists."
},
"409": {
"description": "`webhookdelivery-pruned`: This webhook delivery has been pruned, so it can't be redelivered."
} }
}, },
"security": [ "security": [